tanglebrain 0.16.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.
- tanglebrain/__init__.py +23 -0
- tanglebrain/adapters/__init__.py +15 -0
- tanglebrain/adapters/api.py +65 -0
- tanglebrain/adapters/base.py +46 -0
- tanglebrain/adapters/cli.py +341 -0
- tanglebrain/adapters/openai_compat.py +197 -0
- tanglebrain/classifier.py +99 -0
- tanglebrain/cli.py +251 -0
- tanglebrain/config/pricing.yaml +14 -0
- tanglebrain/config/roster.yaml +129 -0
- tanglebrain/config/settings.yaml +39 -0
- tanglebrain/delegate.py +485 -0
- tanglebrain/gui/__init__.py +10 -0
- tanglebrain/gui/server.py +162 -0
- tanglebrain/gui/static/index.html +295 -0
- tanglebrain/gui/static/logo.png +0 -0
- tanglebrain/gui/views.py +180 -0
- tanglebrain/mcp_server.py +208 -0
- tanglebrain/measurement.py +548 -0
- tanglebrain/roster.py +415 -0
- tanglebrain/roster_edit.py +201 -0
- tanglebrain/router.py +232 -0
- tanglebrain/selector.py +117 -0
- tanglebrain/settings.py +132 -0
- tanglebrain-0.16.0.dist-info/METADATA +369 -0
- tanglebrain-0.16.0.dist-info/RECORD +30 -0
- tanglebrain-0.16.0.dist-info/WHEEL +5 -0
- tanglebrain-0.16.0.dist-info/entry_points.txt +4 -0
- tanglebrain-0.16.0.dist-info/licenses/LICENSE +21 -0
- tanglebrain-0.16.0.dist-info/top_level.txt +1 -0
tanglebrain/roster.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Roster config loader.
|
|
2
|
+
|
|
3
|
+
The roster is a simple, editable YAML list of routable backends — *not* a registry subsystem.
|
|
4
|
+
Adding, removing, or reorganizing a backend is an entry edit, never a code change; this
|
|
5
|
+
modifiability is a first-class requirement.
|
|
6
|
+
|
|
7
|
+
This module parses that YAML into typed objects. It parses *every* entry regardless of which
|
|
8
|
+
adapters are built, so the full roster is always inspectable; whether a given entry is
|
|
9
|
+
*invocable* depends on which adapters exist (``openai-compat`` + ``cli``) and, for ``tier: api``
|
|
10
|
+
entries, on the global ``api_billing_enabled`` gate plus the entry's own ``enabled`` flag — a
|
|
11
|
+
paid entry parses here but stays inert until both are on (see :mod:`tanglebrain.settings`).
|
|
12
|
+
|
|
13
|
+
Each entry::
|
|
14
|
+
|
|
15
|
+
- id: local-model
|
|
16
|
+
tier: local
|
|
17
|
+
invoke: { kind: openai-compat, base_url: "http://.../v1", model: "local-model" }
|
|
18
|
+
cost: free
|
|
19
|
+
good_at: [grunt, code, tools]
|
|
20
|
+
|
|
21
|
+
``invoke.kind`` is one of ``openai-compat`` | ``cli`` | ``api``. ``scrub_env`` enforces the
|
|
22
|
+
session-vs-key safety rule per adapter. ``can_orchestrate`` flags an entry as eligible for the
|
|
23
|
+
orchestrator rotation; ``can_delegate`` flags it as an eligible delegate target an orchestrator
|
|
24
|
+
may offload a sub-task to. ``key_ref`` references a credential without embedding it:
|
|
25
|
+
``file:PATH`` | ``env:NAME`` | ``none``.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
VALID_KINDS = ("openai-compat", "cli", "api")
|
|
36
|
+
VALID_TIERS = ("local", "sub", "api")
|
|
37
|
+
|
|
38
|
+
# Env var pointing at an explicit roster file (highest-precedence default, ~ expanded).
|
|
39
|
+
ROSTER_ENV_VAR = "TANGLEBRAIN_ROSTER"
|
|
40
|
+
# The operator's own roster, kept OUTSIDE the repo (XDG config). Auto-discovered if present.
|
|
41
|
+
USER_ROSTER_SUBPATH = ("tanglebrain", "roster.yaml")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RosterError(ValueError):
|
|
45
|
+
"""Raised when the roster YAML is missing, malformed, or semantically invalid.
|
|
46
|
+
|
|
47
|
+
A subclass of ``ValueError`` so callers can catch it specifically while still treating
|
|
48
|
+
it as the bad-input error it is.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Invoke:
|
|
54
|
+
"""How to invoke one roster entry — the transport-specific call details.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
kind: One of ``openai-compat`` | ``cli`` | ``api``.
|
|
58
|
+
base_url: OpenAI-compat base URL (e.g. the LiteLLM ``/v1`` endpoint). Required for
|
|
59
|
+
``openai-compat``; ``None`` otherwise.
|
|
60
|
+
model: Model id/alias to request. Required for ``openai-compat``; ``None`` otherwise.
|
|
61
|
+
cmd: Argv for a subprocess CLI invocation. Required for ``cli``; ``None`` otherwise.
|
|
62
|
+
A literal ``{prompt}`` token in the argv is replaced with the prompt by the CLI
|
|
63
|
+
adapter; if no token is present the prompt is appended as the final argument.
|
|
64
|
+
scrub_env: Env var names to remove from a subprocess's environment before launch
|
|
65
|
+
(e.g. ``ANTHROPIC_API_KEY``, so ``claude -p`` rides the flat sub, not a billed key).
|
|
66
|
+
parse: Name of the output parser the ``cli`` adapter uses to extract the final text
|
|
67
|
+
from the subprocess stdout (e.g. ``claude-json``, ``gemini-json``, ``plain``).
|
|
68
|
+
``None`` lets the adapter pick its default. Informational for non-``cli`` kinds.
|
|
69
|
+
delegate_args: Extra argv appended to ``cmd`` when the router invokes this entry as an
|
|
70
|
+
orchestrator with local delegation enabled — the per-CLI flags that register the
|
|
71
|
+
local-delegate tool and allow it. A ``{delegate_mcp_json}`` token is substituted
|
|
72
|
+
with the delegate's MCP-server JSON at runtime. Empty = the CLI cannot be handed the
|
|
73
|
+
delegate per-invocation (or doesn't need it).
|
|
74
|
+
key_ref: Credential reference — ``file:PATH`` | ``env:NAME`` | ``none`` — never a raw
|
|
75
|
+
secret. ``None`` means no credential is configured for this entry.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
kind: str
|
|
79
|
+
base_url: str | None = None
|
|
80
|
+
model: str | None = None
|
|
81
|
+
cmd: list[str] | None = None
|
|
82
|
+
scrub_env: list[str] = field(default_factory=list)
|
|
83
|
+
parse: str | None = None
|
|
84
|
+
delegate_args: list[str] = field(default_factory=list)
|
|
85
|
+
key_ref: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class RosterEntry:
|
|
90
|
+
"""One routable backend in the roster.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
id: Unique identifier for the entry (e.g. ``gpt-oss-120b``, ``claude``).
|
|
94
|
+
tier: Cost tier — ``local`` | ``sub`` | ``api``.
|
|
95
|
+
invoke: How to call this entry (see :class:`Invoke`).
|
|
96
|
+
cost: Free-form cost annotation (e.g. ``free``, ``paid``); informational.
|
|
97
|
+
good_at: Tags describing what the entry is good at (drives task-fit routing).
|
|
98
|
+
can_orchestrate: Whether this entry joins the orchestrator rotation.
|
|
99
|
+
can_delegate: Whether this entry is an eligible **delegate target** — a backend an
|
|
100
|
+
orchestrator may hand a sub-task to via the generalized ``delegate`` tool. Explicit
|
|
101
|
+
opt-in (default ``False``), mirroring ``can_orchestrate``; nothing is delegable unless
|
|
102
|
+
declared. The free local tier remains the default target regardless of this flag (the
|
|
103
|
+
``delegate_local`` tool / ``target=None`` path resolves it directly).
|
|
104
|
+
enabled: Per-entry kill-switch. ``True`` by default. Currently enforced only for
|
|
105
|
+
``tier: api`` entries (a disabled paid key is never routable, even with the global
|
|
106
|
+
``api_billing_enabled`` gate on); informational for other tiers.
|
|
107
|
+
budget_usd_month: Optional monthly USD budget annotation for a paid key, which must be a
|
|
108
|
+
number ``> 0`` when present. This is **displayed only** — the hard cap is enforced
|
|
109
|
+
gateway-side on the key, not by TangleBrain. ``None`` (the default)
|
|
110
|
+
means no budget is recorded.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
id: str
|
|
114
|
+
tier: str
|
|
115
|
+
invoke: Invoke
|
|
116
|
+
cost: str | None = None
|
|
117
|
+
good_at: list[str] = field(default_factory=list)
|
|
118
|
+
can_orchestrate: bool = False
|
|
119
|
+
can_delegate: bool = False
|
|
120
|
+
enabled: bool = True
|
|
121
|
+
budget_usd_month: float | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Roster:
|
|
125
|
+
"""An ordered collection of :class:`RosterEntry`, with lookup/filter helpers.
|
|
126
|
+
|
|
127
|
+
Order is preserved from the YAML file because it is meaningful — e.g. the local-first
|
|
128
|
+
selector walks entries in declared order.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, entries: list[RosterEntry]) -> None:
|
|
132
|
+
"""Store the entries and build an id index, rejecting duplicate ids.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
entries: The parsed roster entries, in file order.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
RosterError: If two entries share the same ``id``.
|
|
139
|
+
"""
|
|
140
|
+
self._entries = list(entries)
|
|
141
|
+
self._by_id: dict[str, RosterEntry] = {}
|
|
142
|
+
for entry in self._entries:
|
|
143
|
+
if entry.id in self._by_id:
|
|
144
|
+
raise RosterError(f"duplicate roster entry id: {entry.id!r}")
|
|
145
|
+
self._by_id[entry.id] = entry
|
|
146
|
+
|
|
147
|
+
def __iter__(self):
|
|
148
|
+
"""Iterate entries in declared (file) order."""
|
|
149
|
+
return iter(self._entries)
|
|
150
|
+
|
|
151
|
+
def __len__(self) -> int:
|
|
152
|
+
"""Return the number of entries in the roster."""
|
|
153
|
+
return len(self._entries)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def entries(self) -> list[RosterEntry]:
|
|
157
|
+
"""Return a copy of the entries list, in declared order."""
|
|
158
|
+
return list(self._entries)
|
|
159
|
+
|
|
160
|
+
def by_id(self, entry_id: str) -> RosterEntry:
|
|
161
|
+
"""Return the entry with the given id.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
entry_id: The id to look up.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The matching :class:`RosterEntry`.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
KeyError: If no entry has that id.
|
|
171
|
+
"""
|
|
172
|
+
return self._by_id[entry_id]
|
|
173
|
+
|
|
174
|
+
def in_tier(self, tier: str) -> list[RosterEntry]:
|
|
175
|
+
"""Return all entries in the given tier, in declared order.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
tier: The tier to filter by (e.g. ``local``).
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The matching entries (possibly empty).
|
|
182
|
+
"""
|
|
183
|
+
return [e for e in self._entries if e.tier == tier]
|
|
184
|
+
|
|
185
|
+
def orchestrators(self) -> list[RosterEntry]:
|
|
186
|
+
"""Return the entries flagged ``can_orchestrate`` (the rotation set).
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The orchestrator-capable entries, in declared order.
|
|
190
|
+
"""
|
|
191
|
+
return [e for e in self._entries if e.can_orchestrate]
|
|
192
|
+
|
|
193
|
+
def delegate_targets(self) -> list[RosterEntry]:
|
|
194
|
+
"""Return the entries flagged ``can_delegate`` (the delegate-target menu).
|
|
195
|
+
|
|
196
|
+
These are the backends an orchestrator may hand a sub-task to via the generalized
|
|
197
|
+
``delegate`` tool — the configured, opt-in menu. The free local default target is resolved
|
|
198
|
+
separately (see :func:`tanglebrain.selector.select_local`) and need not be flagged.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The delegate-eligible entries, in declared order.
|
|
202
|
+
"""
|
|
203
|
+
return [e for e in self._entries if e.can_delegate]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_invoke(raw: object, entry_id: str) -> Invoke:
|
|
207
|
+
"""Validate and build an :class:`Invoke` from one entry's ``invoke`` block.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
raw: The raw ``invoke`` value from YAML (expected to be a mapping).
|
|
211
|
+
entry_id: The owning entry's id, for error messages.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
A validated :class:`Invoke`.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
RosterError: If the block is not a mapping, the kind is missing/unknown, or the
|
|
218
|
+
fields required for that kind are absent.
|
|
219
|
+
"""
|
|
220
|
+
if not isinstance(raw, dict):
|
|
221
|
+
raise RosterError(f"entry {entry_id!r}: 'invoke' must be a mapping")
|
|
222
|
+
|
|
223
|
+
kind = raw.get("kind")
|
|
224
|
+
if kind not in VALID_KINDS:
|
|
225
|
+
raise RosterError(
|
|
226
|
+
f"entry {entry_id!r}: invoke.kind must be one of {VALID_KINDS}, got {kind!r}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
base_url = raw.get("base_url")
|
|
230
|
+
model = raw.get("model")
|
|
231
|
+
cmd = raw.get("cmd")
|
|
232
|
+
scrub_env = raw.get("scrub_env", []) or []
|
|
233
|
+
parse = raw.get("parse")
|
|
234
|
+
delegate_args = raw.get("delegate_args", []) or []
|
|
235
|
+
key_ref = raw.get("key_ref")
|
|
236
|
+
|
|
237
|
+
if kind == "openai-compat":
|
|
238
|
+
if not base_url or not model:
|
|
239
|
+
raise RosterError(
|
|
240
|
+
f"entry {entry_id!r}: openai-compat invoke requires 'base_url' and 'model'"
|
|
241
|
+
)
|
|
242
|
+
elif kind == "cli":
|
|
243
|
+
if not cmd or not isinstance(cmd, list):
|
|
244
|
+
raise RosterError(f"entry {entry_id!r}: cli invoke requires a non-empty 'cmd' list")
|
|
245
|
+
elif kind == "api":
|
|
246
|
+
# Paid API is gateway-fronted: base_url is the OpenAI-compatible gateway endpoint, model the
|
|
247
|
+
# alias it exposes, and key_ref a *scoped key* — never a raw provider key. All three are
|
|
248
|
+
# required so a paid entry can never be half-configured.
|
|
249
|
+
if not base_url or not model:
|
|
250
|
+
raise RosterError(
|
|
251
|
+
f"entry {entry_id!r}: api invoke requires 'base_url' and 'model' "
|
|
252
|
+
"(the LiteLLM-fronted endpoint + model alias)"
|
|
253
|
+
)
|
|
254
|
+
if not key_ref:
|
|
255
|
+
raise RosterError(
|
|
256
|
+
f"entry {entry_id!r}: api invoke requires 'key_ref' "
|
|
257
|
+
"(a scoped LiteLLM virtual key reference — never a raw provider key)"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if not isinstance(scrub_env, list):
|
|
261
|
+
raise RosterError(f"entry {entry_id!r}: invoke.scrub_env must be a list")
|
|
262
|
+
|
|
263
|
+
if parse is not None and not isinstance(parse, str):
|
|
264
|
+
raise RosterError(f"entry {entry_id!r}: invoke.parse must be a string")
|
|
265
|
+
|
|
266
|
+
if not isinstance(delegate_args, list):
|
|
267
|
+
raise RosterError(f"entry {entry_id!r}: invoke.delegate_args must be a list")
|
|
268
|
+
|
|
269
|
+
return Invoke(
|
|
270
|
+
kind=kind,
|
|
271
|
+
base_url=base_url,
|
|
272
|
+
model=model,
|
|
273
|
+
cmd=list(cmd) if cmd else None,
|
|
274
|
+
scrub_env=list(scrub_env),
|
|
275
|
+
parse=parse,
|
|
276
|
+
delegate_args=list(delegate_args),
|
|
277
|
+
key_ref=key_ref,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_entry(raw: object) -> RosterEntry:
|
|
282
|
+
"""Validate and build one :class:`RosterEntry` from a YAML mapping.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
raw: The raw entry value from YAML (expected to be a mapping).
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
A validated :class:`RosterEntry`.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
RosterError: If the entry is not a mapping, is missing ``id`` / ``tier`` / ``invoke``,
|
|
292
|
+
or ``tier`` is not one of ``VALID_TIERS``.
|
|
293
|
+
"""
|
|
294
|
+
if not isinstance(raw, dict):
|
|
295
|
+
raise RosterError(f"each roster entry must be a mapping, got {type(raw).__name__}")
|
|
296
|
+
|
|
297
|
+
entry_id = raw.get("id")
|
|
298
|
+
if not entry_id:
|
|
299
|
+
raise RosterError("roster entry is missing required field 'id'")
|
|
300
|
+
|
|
301
|
+
tier = raw.get("tier")
|
|
302
|
+
if not tier:
|
|
303
|
+
raise RosterError(f"entry {entry_id!r}: missing required field 'tier'")
|
|
304
|
+
if tier not in VALID_TIERS:
|
|
305
|
+
raise RosterError(
|
|
306
|
+
f"entry {entry_id!r}: tier must be one of {VALID_TIERS}, got {tier!r}"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if "invoke" not in raw:
|
|
310
|
+
raise RosterError(f"entry {entry_id!r}: missing required field 'invoke'")
|
|
311
|
+
|
|
312
|
+
good_at = raw.get("good_at", []) or []
|
|
313
|
+
if not isinstance(good_at, list):
|
|
314
|
+
raise RosterError(f"entry {entry_id!r}: 'good_at' must be a list")
|
|
315
|
+
|
|
316
|
+
enabled = raw.get("enabled", True)
|
|
317
|
+
if not isinstance(enabled, bool):
|
|
318
|
+
raise RosterError(
|
|
319
|
+
f"entry {entry_id!r}: 'enabled' must be a boolean (true/false), got {enabled!r}"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
budget = raw.get("budget_usd_month")
|
|
323
|
+
if budget is not None:
|
|
324
|
+
# Reject bool explicitly (bool is an int subclass) — a budget of `true` is a config error.
|
|
325
|
+
if isinstance(budget, bool) or not isinstance(budget, (int, float)):
|
|
326
|
+
raise RosterError(
|
|
327
|
+
f"entry {entry_id!r}: 'budget_usd_month' must be a number, got {budget!r}"
|
|
328
|
+
)
|
|
329
|
+
if budget <= 0:
|
|
330
|
+
raise RosterError(
|
|
331
|
+
f"entry {entry_id!r}: 'budget_usd_month' must be > 0, got {budget!r}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return RosterEntry(
|
|
335
|
+
id=entry_id,
|
|
336
|
+
tier=tier,
|
|
337
|
+
invoke=_parse_invoke(raw["invoke"], entry_id),
|
|
338
|
+
cost=raw.get("cost"),
|
|
339
|
+
good_at=list(good_at),
|
|
340
|
+
can_orchestrate=bool(raw.get("can_orchestrate", False)),
|
|
341
|
+
can_delegate=bool(raw.get("can_delegate", False)),
|
|
342
|
+
enabled=enabled,
|
|
343
|
+
budget_usd_month=float(budget) if budget is not None else None,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def packaged_roster_path() -> Path:
|
|
348
|
+
"""Return the path to the **generic example** roster shipped with the package.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
The absolute path to ``tanglebrain/config/roster.yaml`` (the fallback default).
|
|
352
|
+
"""
|
|
353
|
+
return Path(__file__).resolve().parent / "config" / "roster.yaml"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _user_roster_path() -> Path:
|
|
357
|
+
"""The operator's own roster path under XDG config (``~/.config/tanglebrain/roster.yaml``)."""
|
|
358
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
359
|
+
root = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
360
|
+
return root.joinpath(*USER_ROSTER_SUBPATH)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def default_roster_path() -> Path:
|
|
364
|
+
"""Resolve the roster path used when no explicit path is given.
|
|
365
|
+
|
|
366
|
+
The shipped ``config/roster.yaml`` is only a **generic example** — an operator keeps their real
|
|
367
|
+
roster outside the repo. Resolution order (first hit wins):
|
|
368
|
+
|
|
369
|
+
1. ``TANGLEBRAIN_ROSTER`` env var (``~`` expanded) — an explicit override.
|
|
370
|
+
2. ``$XDG_CONFIG_HOME/tanglebrain/roster.yaml`` (or ``~/.config/tanglebrain/roster.yaml``) **if it
|
|
371
|
+
exists** — the operator's own roster, auto-discovered.
|
|
372
|
+
3. The packaged generic example (:func:`packaged_roster_path`).
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
The resolved roster path (existence is checked by :func:`load_roster`, so a bad
|
|
376
|
+
``TANGLEBRAIN_ROSTER`` surfaces a clear "not found" error rather than silently falling back).
|
|
377
|
+
"""
|
|
378
|
+
env = os.environ.get(ROSTER_ENV_VAR)
|
|
379
|
+
if env:
|
|
380
|
+
return Path(env).expanduser()
|
|
381
|
+
user = _user_roster_path()
|
|
382
|
+
if user.exists():
|
|
383
|
+
return user
|
|
384
|
+
return packaged_roster_path()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def load_roster(path: str | os.PathLike[str] | None = None) -> Roster:
|
|
388
|
+
"""Load and validate the roster YAML into a :class:`Roster`.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
path: Path to the roster YAML. When ``None``, the path is resolved by
|
|
392
|
+
:func:`default_roster_path` (``TANGLEBRAIN_ROSTER`` env → ``~/.config/tanglebrain/
|
|
393
|
+
roster.yaml`` → the packaged generic example).
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
The parsed :class:`Roster`.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
RosterError: If the file is missing, is not a YAML list, or any entry is invalid.
|
|
400
|
+
"""
|
|
401
|
+
roster_path = Path(path) if path is not None else default_roster_path()
|
|
402
|
+
if not roster_path.exists():
|
|
403
|
+
raise RosterError(f"roster file not found: {roster_path}")
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
raw = yaml.safe_load(roster_path.read_text())
|
|
407
|
+
except yaml.YAMLError as exc:
|
|
408
|
+
raise RosterError(f"roster file is not valid YAML: {roster_path}: {exc}") from exc
|
|
409
|
+
|
|
410
|
+
if not isinstance(raw, list):
|
|
411
|
+
raise RosterError(
|
|
412
|
+
f"roster file must be a YAML list of entries, got {type(raw).__name__}: {roster_path}"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
return Roster([_parse_entry(item) for item in raw])
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Surgical, comment-preserving roster editor (the knob-panel roster slice).
|
|
2
|
+
|
|
3
|
+
The knob panel lets the operator tune config from the browser. Roster editing needs care because
|
|
4
|
+
``roster.yaml`` carries dense inline comments (per-CLI invocation notes, the paid-tier example) that
|
|
5
|
+
a naive YAML dump would destroy. This module edits a **focused** set of simple per-entry fields *in
|
|
6
|
+
place* — touching only the specific value on the specific line — so every comment, blank line, and
|
|
7
|
+
the nested ``invoke`` block survive byte-for-byte. No new dependency (the GUI's standing
|
|
8
|
+
zero-new-runtime-dep stance).
|
|
9
|
+
|
|
10
|
+
It deliberately does **not** add, remove, or reorder entries, nor edit the ``invoke`` block — those
|
|
11
|
+
still need a hand-edit (they'd require a full comment-preserving YAML round-trip). Editable fields
|
|
12
|
+
are entry-level scalars only:
|
|
13
|
+
|
|
14
|
+
- ``enabled`` / ``can_orchestrate`` — booleans,
|
|
15
|
+
- ``budget_usd_month`` — a positive number, or cleared (``None``) → the line is removed,
|
|
16
|
+
- ``good_at`` — a flow-style list of simple tags.
|
|
17
|
+
|
|
18
|
+
**Safety**: every save re-parses the candidate text with the real :func:`tanglebrain.roster.load_roster`
|
|
19
|
+
*before* it is written (a surgical mistake can never land a malformed roster), then backs up the
|
|
20
|
+
existing file and writes atomically — mirroring the pricing save.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from tanglebrain.measurement import _atomic_write, _backup_dir
|
|
32
|
+
from tanglebrain.roster import RosterError, default_roster_path, load_roster
|
|
33
|
+
|
|
34
|
+
# The only fields this editor will touch (entry-level scalars). Everything else — id, tier, the
|
|
35
|
+
# invoke block, cost — stays hand-edited.
|
|
36
|
+
EDITABLE_FIELDS = ("enabled", "can_orchestrate", "budget_usd_month", "good_at")
|
|
37
|
+
|
|
38
|
+
_GOOD_AT_TAG_RE = re.compile(r"^[\w.\-]+$") # flow-safe tags: no spaces/commas/brackets/quotes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RosterEditError(ValueError):
|
|
42
|
+
"""Raised when a requested roster edit is invalid or cannot be applied safely."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_field(field: str, value: object) -> str | None:
|
|
46
|
+
"""Validate ``value`` for ``field`` and render it as the YAML scalar to write.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
field: One of :data:`EDITABLE_FIELDS`.
|
|
50
|
+
value: The new value (from JSON: ``bool`` / number / ``None`` / list of str).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The rendered YAML value (e.g. ``"true"``, ``"25"``, ``"[a, b]"``), or ``None`` to signal the
|
|
54
|
+
field's line should be **removed** (only ``budget_usd_month`` cleared to ``None``).
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
RosterEditError: If ``field`` is not editable or ``value`` is the wrong type/shape.
|
|
58
|
+
"""
|
|
59
|
+
if field not in EDITABLE_FIELDS:
|
|
60
|
+
raise RosterEditError(f"field {field!r} is not editable (allowed: {', '.join(EDITABLE_FIELDS)})")
|
|
61
|
+
|
|
62
|
+
if field in ("enabled", "can_orchestrate"):
|
|
63
|
+
if not isinstance(value, bool):
|
|
64
|
+
raise RosterEditError(f"{field} must be a boolean, got {value!r}")
|
|
65
|
+
return "true" if value else "false"
|
|
66
|
+
|
|
67
|
+
if field == "budget_usd_month":
|
|
68
|
+
if value is None or value == "":
|
|
69
|
+
return None # cleared → remove the line (absent == no budget)
|
|
70
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
71
|
+
raise RosterEditError(f"budget_usd_month must be a finite number, got {value!r}")
|
|
72
|
+
if value <= 0:
|
|
73
|
+
raise RosterEditError(f"budget_usd_month must be > 0, got {value!r}")
|
|
74
|
+
# Render an integer-valued number without a trailing .0, else the float repr.
|
|
75
|
+
return str(int(value)) if float(value).is_integer() else repr(float(value))
|
|
76
|
+
|
|
77
|
+
# good_at — a list of simple, flow-safe tags.
|
|
78
|
+
if not isinstance(value, list) or any(not isinstance(t, str) for t in value):
|
|
79
|
+
raise RosterEditError(f"good_at must be a list of strings, got {value!r}")
|
|
80
|
+
tags = [t.strip() for t in value]
|
|
81
|
+
for t in tags:
|
|
82
|
+
if not t or not _GOOD_AT_TAG_RE.match(t):
|
|
83
|
+
raise RosterEditError(
|
|
84
|
+
f"good_at tag {t!r} is not a simple tag (allowed: letters, digits, _ . -)"
|
|
85
|
+
)
|
|
86
|
+
return "[" + ", ".join(tags) + "]"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _entry_id_of(line: str) -> str | None:
|
|
90
|
+
"""Return the entry id declared by a ``- id: <id>`` list-item line, or ``None``."""
|
|
91
|
+
m = re.match(r"^-\s+id:\s*(.*)$", line)
|
|
92
|
+
if not m:
|
|
93
|
+
return None
|
|
94
|
+
raw = re.split(r"\s+#", m.group(1), maxsplit=1)[0].strip()
|
|
95
|
+
return raw.strip("\"'")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _find_entry_block(lines: list[str], entry_id: str) -> tuple[int, int]:
|
|
99
|
+
"""Return the ``[start, end)`` line range of ``entry_id``'s block (its indented field lines).
|
|
100
|
+
|
|
101
|
+
The block is the ``- id:`` line plus every following *indented* line, stopping at the first
|
|
102
|
+
column-0 line (a blank line, the next ``- `` item, a ``#`` comment, or EOF).
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
RosterEditError: If no entry with that id is found.
|
|
106
|
+
"""
|
|
107
|
+
start = next((i for i, ln in enumerate(lines) if _entry_id_of(ln) == entry_id), None)
|
|
108
|
+
if start is None:
|
|
109
|
+
raise RosterEditError(f"no roster entry with id {entry_id!r}")
|
|
110
|
+
end = start + 1
|
|
111
|
+
while end < len(lines) and lines[end][:1] in (" ", "\t"):
|
|
112
|
+
end += 1
|
|
113
|
+
return start, end
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _apply_field(lines: list[str], entry_id: str, field: str, rendered: str | None) -> list[str]:
|
|
117
|
+
"""Return a new ``lines`` list with ``field`` set to ``rendered`` on ``entry_id``'s block.
|
|
118
|
+
|
|
119
|
+
Updates the value in place (preserving any trailing inline comment); when the field is absent it
|
|
120
|
+
is appended at the **end of the entry's block** (a fresh 2-space-indent line after all the entry's
|
|
121
|
+
existing lines), which can never splice between a block-style key and its continuation, nor land
|
|
122
|
+
inside the nested ``invoke`` map. Removes the line when ``rendered`` is ``None``.
|
|
123
|
+
"""
|
|
124
|
+
start, end = _find_entry_block(lines, entry_id)
|
|
125
|
+
# The field line is matched only at exactly 2-space indent, so a same-named key nested deeper in
|
|
126
|
+
# the invoke block (4-space) is never mistaken for the top-level field.
|
|
127
|
+
field_re = re.compile(r"^( {2}" + re.escape(field) + r":\s*)(.*?)(\s+#.*)?$")
|
|
128
|
+
|
|
129
|
+
for i in range(start, end):
|
|
130
|
+
m = field_re.match(lines[i])
|
|
131
|
+
if not m:
|
|
132
|
+
continue
|
|
133
|
+
if rendered is None:
|
|
134
|
+
return lines[:i] + lines[i + 1:] # remove the line
|
|
135
|
+
return lines[:i] + [m.group(1) + rendered + (m.group(3) or "")] + lines[i + 1:]
|
|
136
|
+
|
|
137
|
+
# Field absent.
|
|
138
|
+
if rendered is None:
|
|
139
|
+
return lines # nothing to remove
|
|
140
|
+
return lines[:end] + [f" {field}: {rendered}"] + lines[end:]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_roster_edits(
|
|
144
|
+
entry_id: str,
|
|
145
|
+
fields: dict,
|
|
146
|
+
path: str | os.PathLike[str] | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Apply edits to one entry's editable fields, validate, back up, and write atomically.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
entry_id: The id of the (existing) entry to edit.
|
|
152
|
+
fields: ``{field: value}`` for one or more :data:`EDITABLE_FIELDS`.
|
|
153
|
+
path: Target roster YAML. Defaults to the packaged ``config/roster.yaml``.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
RosterEditError: If the entry is unknown, a field/value is invalid, or the resulting YAML
|
|
157
|
+
fails to re-parse as a valid roster (in which case nothing is written).
|
|
158
|
+
"""
|
|
159
|
+
if not fields:
|
|
160
|
+
raise RosterEditError("no fields to edit")
|
|
161
|
+
|
|
162
|
+
target = Path(path) if path is not None else default_roster_path()
|
|
163
|
+
if not target.exists():
|
|
164
|
+
raise RosterEditError(f"roster file not found: {target}")
|
|
165
|
+
|
|
166
|
+
text = target.read_text(encoding="utf-8")
|
|
167
|
+
lines = text.split("\n")
|
|
168
|
+
|
|
169
|
+
_find_entry_block(lines, entry_id) # fail fast on unknown id before rendering anything
|
|
170
|
+
for field, value in fields.items():
|
|
171
|
+
lines = _apply_field(lines, entry_id, field, render_field(field, value))
|
|
172
|
+
candidate = "\n".join(lines)
|
|
173
|
+
|
|
174
|
+
# Validate by re-parsing with the real loader before any write — a surgical slip can never land
|
|
175
|
+
# a malformed roster. Use a sibling temp file so the parse sees exactly what we'd write.
|
|
176
|
+
check = target.with_name(target.name + ".check.tmp")
|
|
177
|
+
try:
|
|
178
|
+
check.write_text(candidate, encoding="utf-8")
|
|
179
|
+
try:
|
|
180
|
+
roster = load_roster(check)
|
|
181
|
+
except RosterError as exc:
|
|
182
|
+
raise RosterEditError(f"edit would produce an invalid roster: {exc}") from exc
|
|
183
|
+
finally:
|
|
184
|
+
check.unlink(missing_ok=True)
|
|
185
|
+
|
|
186
|
+
# Defensive: confirm each edit actually took on the re-parsed entry.
|
|
187
|
+
entry = roster.by_id(entry_id)
|
|
188
|
+
for field, value in fields.items():
|
|
189
|
+
got = getattr(entry, field)
|
|
190
|
+
want = (None if (value is None or value == "") and field == "budget_usd_month"
|
|
191
|
+
else float(value) if field == "budget_usd_month"
|
|
192
|
+
else [t.strip() for t in value] if field == "good_at"
|
|
193
|
+
else value)
|
|
194
|
+
if got != want:
|
|
195
|
+
raise RosterEditError(f"edit to {field!r} did not apply as expected (got {got!r})")
|
|
196
|
+
|
|
197
|
+
backup_dir = _backup_dir()
|
|
198
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ")
|
|
200
|
+
shutil.copy2(target, backup_dir / f"roster-{stamp}.yaml")
|
|
201
|
+
_atomic_write(target, candidate)
|