zettcode-engine 0.3.4__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 (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
zettcode/config.py ADDED
@@ -0,0 +1,1088 @@
1
+ """Validated settings for the ZettCode runtime, and the file they come from.
2
+
3
+ ``~/.zettcode/config.toml`` (or ``$ZETTCODE_CONFIG``) lists the models ZettCode
4
+ may call: an OpenAI-compatible chat-completions or Responses endpoint, or
5
+ Anthropic's Messages API, one entry each. The first model is active at startup;
6
+ ``/model`` selects another for later requests. The same file says whether
7
+ project ``AGENTS.md`` files are read, whether the model may ask questions, where
8
+ skills live, which MCP server file to read, how much transcript to keep on
9
+ screen, and whether to look for a newer release; session storage and other
10
+ runtime settings keep code defaults.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from collections.abc import Mapping, Sequence
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import TypeVar, overload
21
+
22
+ from zett_agent.extensions.shell_approval import ShellApprovalMode
23
+ from zett_agent.model import ReasoningEffort
24
+
25
+ from zettcode._compat import tomllib
26
+ from zettcode.paths import (
27
+ DEFAULT_HARNESS_DIR,
28
+ DEFAULT_MCP_CONFIG,
29
+ DEFAULT_PLUGINS_FILE,
30
+ DEFAULT_STORE,
31
+ DEFAULT_THEME_FILE,
32
+ DEFAULT_THEMES_DIR,
33
+ DEFAULT_UPDATE_FILE,
34
+ check_config_path,
35
+ plugins_dir,
36
+ )
37
+ from zettcode.providers import Api
38
+ from zettcode.statusline_parts import NAMES as STATUSLINE_NAMES
39
+ from zettcode.statusline_parts import TURN_PREFIX
40
+
41
+ #: The concrete type :func:`_typed` verifies a raw config value against.
42
+ T = TypeVar("T")
43
+
44
+ #: Top-level keys the config file may set; anything else is a typo.
45
+ CONFIGURABLE = frozenset(
46
+ {
47
+ "models",
48
+ "transcript",
49
+ "agents_md",
50
+ "ask_user",
51
+ "skills",
52
+ "mcp",
53
+ "plugins",
54
+ "update",
55
+ "harness",
56
+ "shell",
57
+ "sessions",
58
+ "compaction",
59
+ "ui",
60
+ "statusline",
61
+ }
62
+ )
63
+
64
+ #: Keys one ``[[models]]`` entry may set.
65
+ MODEL_KEYS = frozenset(
66
+ {
67
+ "model",
68
+ "display_model",
69
+ "token",
70
+ "base_url",
71
+ "anthropic",
72
+ "responses_api",
73
+ "chat",
74
+ "multimodal",
75
+ "temperature",
76
+ "web_search",
77
+ "web_search_name",
78
+ "context_window",
79
+ "compact_percent",
80
+ "reasoning_efforts",
81
+ }
82
+ )
83
+
84
+ #: Keys the ``[transcript]`` table may set.
85
+ TRANSCRIPT_KEYS = frozenset({"max_entries", "mouse_scroll_speed", "tips"})
86
+
87
+ #: Keys the ``[agents_md]`` table may set.
88
+ AGENTS_MD_KEYS = frozenset({"enabled"})
89
+
90
+ #: Keys the ``[ask_user]`` table may set.
91
+ ASK_USER_KEYS = frozenset({"enabled"})
92
+
93
+ #: Keys the ``[skills]`` table may set.
94
+ SKILL_KEYS = frozenset({"enabled", "roots", "scope"})
95
+
96
+ #: Keys the ``[mcp]`` table may set.
97
+ MCP_KEYS = frozenset({"enabled", "config", "scope"})
98
+
99
+ #: Keys the ``[compaction]`` table may set.
100
+ COMPACTION_KEYS = frozenset({"self"})
101
+
102
+ #: Where a directory-backed feature is looked for: the user's own files, the workspace's, or both.
103
+ #: A workspace file sits beside the project and is reviewed with it; the user's is the fallback every
104
+ #: checkout shares.
105
+ SCOPES = frozenset({"global", "workspace", "both"})
106
+
107
+ #: The scope a ``[skills]`` or ``[mcp]`` table uses when it names none: both, merged. The user's own
108
+ #: files and the workspace's are used together, the workspace's winning a name inside that project. A
109
+ #: workspace MCP entry is a command that gets run and a skill is instructions the model follows, and both
110
+ #: come from whatever repository is open; a reader who does not want a checkout's files read says
111
+ #: ``scope = "global"``.
112
+ DEFAULT_SCOPE = "both"
113
+
114
+ #: Keys the ``[plugins]`` table may set.
115
+ PLUGIN_KEYS = frozenset({"enabled", "disable"})
116
+
117
+ #: Keys the ``[update]`` table may set.
118
+ UPDATE_KEYS = frozenset({"enabled"})
119
+
120
+ #: Keys the ``[sessions]`` table may set.
121
+ SESSIONS_KEYS = frozenset({"enabled"})
122
+
123
+ #: Keys the ``[ui]`` table may set.
124
+ UI_KEYS = frozenset({"theme", "pet", "pet_position"})
125
+
126
+ #: The statusline parts that are hidden by default and made visible by choice.
127
+ OFF_BY_DEFAULT: frozenset[str] = frozenset({"pet", "turn-calls", "turn-tools"})
128
+
129
+ #: Keys the ``[statusline]`` table may set: the name of each part of the header and the status line.
130
+ STATUSLINE_KEYS = frozenset(STATUSLINE_NAMES)
131
+
132
+ #: Keys the ``[shell]`` table may set.
133
+ SHELL_KEYS = frozenset({"network"})
134
+
135
+ #: Keys the ``[harness]`` table may set.
136
+ HARNESS_KEYS = frozenset({"subagent", "subagent_max_iterations", "subagent_messaging", "todowrite", "jit"})
137
+
138
+ #: Model calls one subagent may make for the task it was given. A child that is exploring or editing
139
+ #: a real codebase makes dozens (a search, a read, an edit, a re-read, for each file), and the framework's
140
+ #: own default of 18 ended such a task halfway with a half-finished report; this is a ceiling against a
141
+ #: child that never stops, not a target.
142
+ DEFAULT_SUBAGENT_MAX_ITERATIONS = 512
143
+
144
+ #: Where ZettCode's own skills live, searched after any ``[skills] roots`` and after the workspace's
145
+ #: own directory. The user-level fallback for every checkout.
146
+ DEFAULT_SKILL_ROOT = "~/.zettcode/skills"
147
+
148
+ #: The skills a workspace may ship, relative to it. Scanned for ``[skills] scope`` of ``workspace``
149
+ #: or ``both``, ahead of the user's own, so a project's version of a skill wins in that project.
150
+ WORKSPACE_SKILL_ROOT = ".zettcode/skills"
151
+
152
+ #: The same two places under the ``.agents`` directory that other coding agents share: ``~/.agents/skills`` for
153
+ #: the user and ``<workspace>/.agents/skills`` for a project. Each is scanned right after the ``.zettcode``
154
+ #: directory of its own scope, so a skill written for one of those tools is found with no copying, and a
155
+ #: ZettCode skill of the same name in the same scope still wins.
156
+ DEFAULT_AGENTS_SKILL_ROOT = "~/.agents/skills"
157
+ WORKSPACE_AGENTS_SKILL_ROOT = ".agents/skills"
158
+
159
+ #: The MCP server file a workspace may ship, relative to it. Read for ``[mcp] scope`` of
160
+ #: ``workspace`` or ``both``, and merged over the user's file by server name.
161
+ WORKSPACE_MCP_CONFIG = ".zettcode/mcp.json"
162
+
163
+ #: The same file under the shared ``.agents`` directory, for the workspace and for the user. Each is read right
164
+ #: after the ``.zettcode`` file of its own scope, so it only adds servers a ZettCode file does not name. The user's
165
+ #: is not read when ``[mcp] config`` names a file: whoever points at a team file means that file.
166
+ WORKSPACE_AGENTS_MCP_CONFIG = ".agents/mcp.json"
167
+ DEFAULT_AGENTS_MCP_CONFIG = "~/.agents/mcp.json"
168
+
169
+ #: API root of an Anthropic model whose entry names no ``base_url``. The
170
+ #: Messages API lives at the root itself, not under ``/v1``.
171
+ DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"
172
+
173
+ #: Routes a client appends to ``base_url`` itself. Writing one out is a common
174
+ #: reading of "the API address", and keeping it would double the path —
175
+ #: ``…/v1/messages/v1/messages``, which a gateway answers with 404 for a route it
176
+ #: does not serve — so the trailing route is removed and whatever comes before it
177
+ #: is kept as written.
178
+ CLIENT_ROUTES = ("/chat/completions", "/v1/messages", "/responses")
179
+
180
+ #: Tokens per model a request may carry before it is compacted, when the config
181
+ #: does not say otherwise; and the share of the trigger kept verbatim.
182
+ DEFAULT_CONTEXT_WINDOW = 128_000
183
+ DEFAULT_COMPACT_PERCENT = 80.0
184
+ KEEP_SHARE = 4
185
+
186
+ #: The sampling temperature a model may be given: 0 is the most deterministic. OpenAI-compatible endpoints take up to
187
+ #: 2; Anthropic's Messages API stops at 1, which is checked for an ``anthropic`` entry.
188
+ MAX_TEMPERATURE = 2.0
189
+ MAX_ANTHROPIC_TEMPERATURE = 1.0
190
+
191
+ #: The hosted search tool an entry gets when it switches ``web_search`` on and names no ``web_search_name``. The
192
+ #: type is the provider's own wire name, so it differs by API: Anthropic's carries a version, and DeepSeek's
193
+ #: Anthropic-compatible endpoint knows only the older one. Every other API is sent plain ``web_search``, which is what
194
+ #: OpenAI's Responses API and the gateways that copy it call it.
195
+ DEFAULT_WEB_SEARCH_NAME = "web_search"
196
+ DEFAULT_ANTHROPIC_WEB_SEARCH_NAME = "web_search_20260318"
197
+ DEFAULT_DEEPSEEK_ANTHROPIC_WEB_SEARCH_NAME = "web_search_20250305"
198
+
199
+ #: What is sent as the key of a model that has none: a local server (Ollama, LM Studio, vLLM) asks for no key, but
200
+ #: the clients that speak to it refuse to be built without one. A server that does want a key answers 401, which is
201
+ #: the message the reader needs.
202
+ KEYLESS_TOKEN = "none"
203
+
204
+ #: Conversation entries kept on screen before the oldest are dropped. The
205
+ #: session file keeps the whole tree, so this bounds scrollback, not history.
206
+ DEFAULT_TRANSCRIPT_MAX_ENTRIES = 1024
207
+
208
+ #: Rows one mouse-wheel event scrolls the transcript by: one, as Codex does, however fast they come.
209
+ DEFAULT_MOUSE_SCROLL_SPEED = 1.0
210
+
211
+ #: The fastest it may be set to: a notch of the wheel moves a page, and no more can be wanted.
212
+ MAX_MOUSE_SCROLL_SPEED = 20.0
213
+
214
+ #: What a path that needs a model is refused with on a machine that has none configured. One sentence for the
215
+ #: runtime, the SDK and the file editor, because the same state reached from three directions should read the same;
216
+ #: a shell that *paints* instead words its own (see invariant S23) — a first-run guide, not a refusal. The server
217
+ #: has a fourth wording, ``add one with POST /models``, because that is the instruction over HTTP.
218
+ NO_MODEL_REFUSAL = "No model is configured; add one with /config"
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class ModelConfig:
223
+ """One configured model: what to call, where, with which token, over which API.
224
+
225
+ Three request protocols are reachable from one entry. The default is an
226
+ OpenAI-compatible chat-completions endpoint, which ``chat = true`` can also
227
+ say outright; ``responses_api`` chooses the
228
+ Responses protocol on such an endpoint; ``anthropic`` chooses Anthropic's
229
+ Messages API, which is not OpenAI-compatible and is served by its own
230
+ adapter. The three are alternatives, not layers.
231
+
232
+ Attributes:
233
+ model: Model id sent to the endpoint; must not be blank.
234
+ token: Credential for the endpoint. May be empty: the entry then takes the key from
235
+ ``OPENAI_API_KEY`` (``ANTHROPIC_API_KEY`` for an Anthropic model), and when there is none either, the
236
+ model is called without one, as a local server wants.
237
+ display_model: Name shown in the UI; ``None`` shows :attr:`model`.
238
+ base_url: Address the client appends its route to; a trailing route the
239
+ entry wrote out is dropped. ``None`` uses the client's default,
240
+ which is the Anthropic address for an Anthropic model and the
241
+ vendor's own for an OpenAI-compatible one.
242
+ anthropic: Speak Anthropic's Messages API instead of an
243
+ OpenAI-compatible one.
244
+ responses_api: Use the Responses API rather than chat completions, on
245
+ an OpenAI-compatible endpoint.
246
+ chat: Say that the endpoint speaks chat completions. It is what an entry that sets neither of the other two
247
+ already means; writing it makes the choice readable in the file and is refused beside either of them.
248
+ multimodal: Whether the model accepts images as well as text.
249
+ temperature: Sampling temperature sent with every request, from 0 (the most deterministic) to 2, or to 1 for
250
+ an Anthropic model. ``None`` sends none, and the provider uses its own default.
251
+ web_search: Let the provider search the web for the model. The search is hosted: the endpoint runs it and
252
+ the model answers from the results, so ZettCode fetches nothing itself. DeepSeek is searched only over
253
+ its Anthropic-compatible endpoint (``anthropic = true``); its chat endpoint is refused.
254
+ web_search_name: The exact tool type the endpoint expects for that search, for a model whose provider names
255
+ it differently. ``None`` takes :attr:`search_name`'s default for the API.
256
+ context_window: Tokens the model can carry, which is what ``/context``
257
+ measures against.
258
+ compact_percent: Share of :attr:`context_window` at which a request is
259
+ compacted, in percent.
260
+ reasoning_efforts: The reasoning levels this model accepts, cheapest first. The empty
261
+ tuple, the default, means every level: a provider that cannot express one maps it
262
+ onto its closest, which is what makes the default right for an endpoint whose
263
+ support is unknown. An entry names the subset when the endpoint rejects the rest.
264
+ """
265
+
266
+ model: str
267
+ token: str = ""
268
+ display_model: str | None = None
269
+ base_url: str | None = None
270
+ anthropic: bool = False
271
+ responses_api: bool = False
272
+ multimodal: bool = False
273
+ context_window: int = DEFAULT_CONTEXT_WINDOW
274
+ compact_percent: float = DEFAULT_COMPACT_PERCENT
275
+ reasoning_efforts: tuple[ReasoningEffort, ...] = ()
276
+ chat: bool = False
277
+ temperature: float | None = None
278
+ web_search: bool = False
279
+ web_search_name: str | None = None
280
+
281
+ def __post_init__(self) -> None:
282
+ """Reject a model that cannot be called."""
283
+ if self.token != self.token.strip():
284
+ # Whitespace around a key is a paste that took the newline with it, never part of the key.
285
+ object.__setattr__(self, "token", self.token.strip())
286
+ if not self.model.strip():
287
+ raise ValueError("Model cannot be empty")
288
+ if self.anthropic and self.responses_api:
289
+ raise ValueError(
290
+ f"Model {self.model!r} cannot use both APIs: the Responses API is OpenAI-compatible only, "
291
+ "so clear 'responses_api' or 'anthropic'"
292
+ )
293
+ if self.chat and (self.anthropic or self.responses_api):
294
+ other = "anthropic" if self.anthropic else "responses_api"
295
+ raise ValueError(
296
+ f"Model {self.model!r} sets both 'chat' and {other!r}: they are alternatives, so keep one of them"
297
+ )
298
+ if self.temperature is not None:
299
+ limit = MAX_ANTHROPIC_TEMPERATURE if self.anthropic else MAX_TEMPERATURE
300
+ if isinstance(self.temperature, bool) or not 0.0 <= self.temperature <= limit:
301
+ raise ValueError(
302
+ f"temperature must be from 0 to {limit:g} for model {self.model!r}, not {self.temperature!r}"
303
+ )
304
+ if self.web_search_name is not None:
305
+ name = self.web_search_name.strip()
306
+ object.__setattr__(self, "web_search_name", name or None)
307
+ if self.web_search and self.deepseek and not self.anthropic:
308
+ raise ValueError(
309
+ f"Model {self.model!r} cannot search the web: DeepSeek serves web search only on its "
310
+ "Anthropic-compatible endpoint, so set 'anthropic = true' with that base_url, or clear 'web_search'"
311
+ )
312
+ if self.base_url is not None:
313
+ root = _without_client_route(self.base_url)
314
+ if root != self.base_url:
315
+ object.__setattr__(self, "base_url", root)
316
+ if self.context_window < 1:
317
+ raise ValueError(f"context_window must be positive for model {self.model!r}")
318
+ if not 0 < self.compact_percent <= 100:
319
+ raise ValueError(f"compact_percent must be between 0 and 100 for model {self.model!r}")
320
+ # Cheapest first, one of each: the order is the picker's rail, and a repeat would put two
321
+ # nodes on one level.
322
+ levels = tuple(sorted(set(self.reasoning_efforts), key=tuple(ReasoningEffort).index))
323
+ if levels != self.reasoning_efforts:
324
+ object.__setattr__(self, "reasoning_efforts", levels)
325
+
326
+ @property
327
+ def shown_name(self) -> str:
328
+ """Return the name to display for the model."""
329
+ return self.display_model or self.model
330
+
331
+ @property
332
+ def deepseek(self) -> bool:
333
+ """Return whether this entry is a DeepSeek model, by its id or by the address it is served from."""
334
+ return "deepseek" in self.model.lower() or "deepseek" in (self.base_url or "").lower()
335
+
336
+ @property
337
+ def search_name(self) -> str:
338
+ """Return the hosted search tool type to send: the entry's own, or the default for its API."""
339
+ if self.web_search_name:
340
+ return self.web_search_name
341
+ if not self.anthropic:
342
+ return DEFAULT_WEB_SEARCH_NAME
343
+ return DEFAULT_DEEPSEEK_ANTHROPIC_WEB_SEARCH_NAME if self.deepseek else DEFAULT_ANTHROPIC_WEB_SEARCH_NAME
344
+
345
+ @property
346
+ def can_search(self) -> bool:
347
+ """Return whether the endpoint this entry names serves hosted web search at all.
348
+
349
+ What a form shows as available: DeepSeek's chat endpoint does not, every other combination is left to the
350
+ endpoint, since a gateway may serve what its vendor does not.
351
+ """
352
+ return not (self.deepseek and not self.anthropic)
353
+
354
+ @property
355
+ def api(self) -> Api:
356
+ """Return which API this model speaks: ``anthropic``, ``responses``, or ``chat``.
357
+
358
+ Named for the picker and for messages: the three are one model entry's
359
+ whole choice of protocol, and a reader needs to see at a glance which
360
+ one an entry they wrote actually selected.
361
+ """
362
+ if self.anthropic:
363
+ return Api.ANTHROPIC
364
+ return Api.RESPONSES if self.responses_api else Api.CHAT
365
+
366
+ @property
367
+ def efforts(self) -> tuple[ReasoningEffort, ...]:
368
+ """Return the reasoning levels this model accepts, cheapest first.
369
+
370
+ Every level when the entry names none: a provider maps one it cannot express onto its closest, so
371
+ the widest choice is the honest default for an endpoint whose support the entry does not describe.
372
+ """
373
+ return self.reasoning_efforts or tuple(ReasoningEffort)
374
+
375
+ def accepted_effort(self, level: ReasoningEffort) -> ReasoningEffort:
376
+ """Return the level to send when ``level`` was asked for, moved to the nearest one that is accepted.
377
+
378
+ A level the model does not take is moved, never refused: the levels are ordered, and the closest
379
+ one is what a caller who asked for ``ultra`` on a model that stops at ``high`` means. A tie goes
380
+ to the cheaper level, which is the one the endpoint is known to serve.
381
+
382
+ Args:
383
+ level: The level that was asked for.
384
+ """
385
+ accepted = self.efforts
386
+ if level in accepted:
387
+ return level
388
+ order = tuple(ReasoningEffort)
389
+ position = order.index(level)
390
+ return min(accepted, key=lambda candidate: (abs(order.index(candidate) - position), order.index(candidate)))
391
+
392
+ @property
393
+ def compaction_max_tokens(self) -> int:
394
+ """Return the estimated context size at which this model is compacted."""
395
+ return max(1, round(self.context_window * self.compact_percent / 100))
396
+
397
+ @property
398
+ def compaction_keep_tokens(self) -> int:
399
+ """Return the recent tokens a compaction keeps: the last quarter of the trigger."""
400
+ return max(1, self.compaction_max_tokens // KEEP_SHARE)
401
+
402
+
403
+ @dataclass(frozen=True, slots=True)
404
+ class ZettCodeConfig:
405
+ """All process-level settings needed to construct one coding runtime.
406
+
407
+ Attributes:
408
+ workspace: Directory the agent works in; must exist, and ``~`` is
409
+ expanded before validation.
410
+ store: Directory holding the JSONL session tree, one file per session;
411
+ ``~`` is expanded. Set programmatically; it is not a TOML setting.
412
+ models: Available models, in file order; the first is active at startup.
413
+ theme_file: Optional TOML palette loaded on top of ``theme``.
414
+ theme: Name of the palette to start in (``[ui] theme``): a built-in one, or a file of the reader's in
415
+ ``themes_dir``. ``None`` follows the terminal's light or dark. A ``theme_file`` wins over it.
416
+ themes_dir: Where the reader's own palettes are, one ``<name>.toml`` each.
417
+ pet: Name of the pet to start with (``[ui] pet``): one the built-in plugin or another plugin offers.
418
+ ``None`` is the default pet.
419
+ pet_position: Where the pet stands, as the share of the free room it is at, left to right and top to
420
+ bottom (``[ui] pet_position = [1.0, 0.5]``). Dragging the pet writes it, so the next start puts
421
+ the pet back where it was left; ``None`` is the default place, the right edge halfway down.
422
+ reasoning_effort: How much reasoning budget to request per turn.
423
+ shell_approval: When the agent must ask before running a shell command.
424
+ reduced_motion: Suppress decorative animation.
425
+ parallel_tool_call: Let the agent issue tool calls in parallel.
426
+ max_iterations: Tool-call rounds allowed in one turn.
427
+ transcript_max_entries: Conversation entries the shell keeps on screen;
428
+ older ones are dropped from the display while the session file
429
+ keeps the whole tree.
430
+ mouse_scroll_speed: Rows one mouse-wheel event scrolls the transcript
431
+ by. A fraction is carried to the next event, so ``0.5`` is a row
432
+ every second event and ``3`` is three rows a notch.
433
+ tips: Show a small tip under the waiting row while a request runs.
434
+ agents_md_enabled: Read the ``AGENTS.md`` files that apply to the
435
+ workspace and give them to the model as project instructions.
436
+ ask_user_enabled: Give the model the ``ask_user`` tool, which pauses a
437
+ turn until the reader answers a question in the shell.
438
+ skills_enabled: Discover local skills and advertise them to the model.
439
+ skill_roots: Extra skill directories, searched before the directory :attr:`skills_scope`
440
+ names; a relative entry is resolved against the workspace.
441
+ skills_scope: Which of the workspace's ``.zettcode/skills`` and ``.agents/skills`` and the user's
442
+ ``~/.zettcode/skills`` and ``~/.agents/skills`` to scan: ``"both"`` (the default) the project's first, so its version of
443
+ a skill wins inside it; ``"workspace"`` the project's only, ``"global"`` the user's only.
444
+ mcp_enabled: Load MCP servers from :attr:`mcp_config` and the workspace's ``.zettcode/mcp.json``.
445
+ mcp_config: JSON file naming MCP servers; ``None`` uses ``~/.zettcode/mcp.json``.
446
+ mcp_scope: Which MCP server files to read, with the same three words; ``"both"`` (the default)
447
+ merges them, a server named in both taken from the workspace's file.
448
+ self_compaction: Whether a session lets the model request compaction unless it says otherwise;
449
+ the default a new session starts with; the terminal's ``/plugins`` switch writes it.
450
+ statusline_hidden: The names of the header and status-line parts the reader switched off in the
451
+ ``[statusline]`` table (``ctx = false``); see :mod:`zettcode.statusline`. Every part not named
452
+ is shown.
453
+ plugins_enabled: Load third-party plugins published under the
454
+ ``zettcode.plugins`` entry-point group. The builtin rows always
455
+ load, so switching this off leaves the shell looking the same.
456
+ disabled_plugins: Entry-point names not to load when
457
+ :attr:`plugins_enabled` is true; a way to switch off one plugin
458
+ without uninstalling it. This is the configuration file's own
459
+ ``[plugins] disable`` list; the names the older switch file adds are merged in by
460
+ :attr:`off_names`, so a switch can tell the two apart.
461
+ plugin_settings: The ``[plugins.<name>]`` tables, by plugin name, exactly as written. A plugin declares
462
+ what its own keys mean and what they default to (``ctx.settings``); the configuration only carries the
463
+ reader's values, so a key no plugin knows is harmless and a plugin that ships later needs no change
464
+ here.
465
+ subagent_enabled: Offer the ``task`` tool, whose profiles run a child
466
+ agent with its own context and session log.
467
+ subagent_max_iterations: Model calls one such child may make for one task.
468
+ subagent_messaging: Give the ``coding`` child ``list_sessions`` and ``send_message`` too. Off by default: a
469
+ child works for one task of one session, and speaking to other sessions is that session's own business.
470
+ todowrite_enabled: Offer the ``todo_write`` tool, whose list the shell
471
+ shows in its plan panel. Off by default: a plan is a turn spent
472
+ updating it, and not every session wants one.
473
+ jit_harness: Start with the just-in-time harness on: the model may write small Python tools of its own
474
+ and run them. Off by default, because what it writes runs in this process, outside the shell's
475
+ permission; the terminal's ``/plugins`` switch writes it for the next start.
476
+ shell_network: Let a command that runs in the ``safe`` sandbox use the network. Off by default; ``[shell]
477
+ network = true`` turns it on. ``full`` always has the network, and so does a machine with no sandbox.
478
+ sessions_enabled: Register with the background session broker, so this session can be
479
+ messaged by the others and message them: ``@name`` in the composer names a
480
+ session for the model, and the ``send_message`` and ``list_sessions`` tools reach it.
481
+ update_enabled: Ask PyPI, in the background, whether a newer release
482
+ exists, and offer it on a later start.
483
+ update_file: Where that check leaves what it found; one small JSON file.
484
+ harness_dir: Where the just-in-time harness keeps each session's tools, one folder per session id. Directly
485
+ under ``~/.zettcode`` by default, not inside the session store, so clearing sessions does not take them.
486
+ plugins_file: The older switch file. It is still read and merged with ``[plugins] disable``; a switch written
487
+ now removes the name from it, so it cannot go on overriding the configuration.
488
+ plugins_dir: Where single-file plugins live, one ``.py`` each; a file there is loaded at the next start
489
+ instead of being installed into site-packages.
490
+ """
491
+
492
+ workspace: Path
493
+ models: tuple[ModelConfig, ...]
494
+ store: Path = DEFAULT_STORE
495
+ theme_file: Path | None = None
496
+ theme: str | None = None
497
+ themes_dir: Path = DEFAULT_THEMES_DIR
498
+ pet: str | None = None
499
+ pet_position: tuple[float, float] | None = None
500
+ reasoning_effort: ReasoningEffort = ReasoningEffort.MEDIUM
501
+ shell_approval: ShellApprovalMode = ShellApprovalMode.REVIEW
502
+ reduced_motion: bool = False
503
+ parallel_tool_call: bool = True
504
+ max_iterations: int = 360
505
+ transcript_max_entries: int = DEFAULT_TRANSCRIPT_MAX_ENTRIES
506
+ mouse_scroll_speed: float = DEFAULT_MOUSE_SCROLL_SPEED
507
+ tips: bool = True
508
+ agents_md_enabled: bool = True
509
+ ask_user_enabled: bool = True
510
+ skills_enabled: bool = True
511
+ skill_roots: tuple[Path, ...] = ()
512
+ skills_scope: str = DEFAULT_SCOPE
513
+ mcp_enabled: bool = True
514
+ mcp_config: Path | None = None
515
+ mcp_scope: str = DEFAULT_SCOPE
516
+ self_compaction: bool = False
517
+ statusline_hidden: tuple[str, ...] = ()
518
+ plugins_enabled: bool = True
519
+ disabled_plugins: tuple[str, ...] = ()
520
+ #: The reader's own keys for a plugin, from its ``[plugins.<name>]`` table; the plugin supplies the defaults.
521
+ plugin_settings: Mapping[str, Mapping[str, object]] = field(default_factory=dict)
522
+ subagent_enabled: bool = True
523
+ subagent_max_iterations: int = DEFAULT_SUBAGENT_MAX_ITERATIONS
524
+ subagent_messaging: bool = False
525
+ todowrite_enabled: bool = False
526
+ jit_harness: bool = False
527
+ shell_network: bool = False
528
+ sessions_enabled: bool = True
529
+ update_enabled: bool = True
530
+ update_file: Path = DEFAULT_UPDATE_FILE
531
+ harness_dir: Path = DEFAULT_HARNESS_DIR
532
+ #: Plugins the reader switched off from the shell; a JSON file, read at the next start.
533
+ plugins_file: Path = DEFAULT_PLUGINS_FILE
534
+ #: Single-file plugins: one ``.py`` each, loaded at the next start instead of being installed into site-packages.
535
+ plugins_dir: Path = field(default_factory=plugins_dir)
536
+
537
+ def __post_init__(self) -> None:
538
+ """Normalize the paths and reject settings that cannot build a runtime.
539
+
540
+ No models is not refused here: the terminal starts on a machine that has never been set up, paints, and
541
+ lets ``/config`` write the first entry, while :func:`load_config` still refuses a file with none for
542
+ every caller that asked to (its default). What cannot build a runtime is the *build*, and the runtime
543
+ says so from the one place that needs a model.
544
+ """
545
+ workspace = self.workspace.expanduser().resolve()
546
+ store = self.store.expanduser().resolve()
547
+ if not workspace.is_dir():
548
+ raise ValueError(f"Workspace is not a directory: {workspace}")
549
+ if self.max_iterations < 1:
550
+ raise ValueError("max_iterations must be positive")
551
+ if self.subagent_max_iterations < 1:
552
+ raise ValueError("subagent_max_iterations must be positive")
553
+ if self.transcript_max_entries < 1:
554
+ raise ValueError("transcript_max_entries must be positive")
555
+ if not 0 < self.mouse_scroll_speed <= MAX_MOUSE_SCROLL_SPEED:
556
+ raise ValueError(f"mouse_scroll_speed must be greater than 0 and at most {MAX_MOUSE_SCROLL_SPEED:g}")
557
+ if self.skills_scope not in SCOPES:
558
+ raise ValueError(f"skills_scope must be one of {', '.join(sorted(SCOPES))}")
559
+ if self.mcp_scope not in SCOPES:
560
+ raise ValueError(f"mcp_scope must be one of {', '.join(sorted(SCOPES))}")
561
+ if stray := [
562
+ name for name in self.statusline_hidden if name not in STATUSLINE_KEYS and not name.startswith(TURN_PREFIX)
563
+ ]:
564
+ raise ValueError(f"statusline_hidden names no part: {', '.join(stray)}")
565
+ object.__setattr__(self, "workspace", workspace)
566
+ object.__setattr__(self, "store", store)
567
+ object.__setattr__(
568
+ self,
569
+ "skill_roots",
570
+ tuple(_resolve_root(root, workspace) for root in self.skill_roots),
571
+ )
572
+ if self.theme_file is not None:
573
+ object.__setattr__(self, "theme_file", self.theme_file.expanduser().resolve())
574
+ if self.mcp_config is not None:
575
+ object.__setattr__(self, "mcp_config", self.mcp_config.expanduser().resolve())
576
+ object.__setattr__(self, "update_file", self.update_file.expanduser().resolve())
577
+ object.__setattr__(self, "harness_dir", self.harness_dir.expanduser().resolve())
578
+ object.__setattr__(self, "plugins_file", self.plugins_file.expanduser().resolve())
579
+ object.__setattr__(self, "plugins_dir", self.plugins_dir.expanduser().resolve())
580
+ object.__setattr__(self, "themes_dir", self.themes_dir.expanduser().resolve())
581
+
582
+ def skill_search_roots(self) -> tuple[Path, ...]:
583
+ """Return every skill directory to scan, most specific first.
584
+
585
+ The configured roots come first so a run can point at a team directory or at a project-local
586
+ one, then the directory :attr:`skills_scope` names: a project's skills ahead of the user's, so
587
+ a name declared in both belongs to the project the reader is in. A name declared twice belongs
588
+ to the earlier directory.
589
+ """
590
+ scope = self.skills_scope
591
+ roots: list[Path] = list(self.skill_roots)
592
+ if scope in ("workspace", "both"):
593
+ roots.append(_resolve_root(WORKSPACE_SKILL_ROOT, self.workspace))
594
+ roots.append(_resolve_root(WORKSPACE_AGENTS_SKILL_ROOT, self.workspace))
595
+ if scope in ("global", "both"):
596
+ roots.append(_resolve_root(DEFAULT_SKILL_ROOT, self.workspace))
597
+ roots.append(_resolve_root(DEFAULT_AGENTS_SKILL_ROOT, self.workspace))
598
+ # Ordered, one of each: a configured root may name the same directory a scope does.
599
+ return tuple(dict.fromkeys(roots))
600
+
601
+ def mcp_files(self) -> tuple[Path, ...]:
602
+ """Return the MCP server files to read, the one that wins a server name first.
603
+
604
+ The workspace's file comes first for the same reason its skills do, and a file ``[mcp] config``
605
+ names replaces the user's own rather than joining it: a reader who points at a team file means
606
+ that file.
607
+ """
608
+ scope = self.mcp_scope
609
+ files: list[Path] = []
610
+ if scope in ("workspace", "both"):
611
+ files.append(_resolve_root(WORKSPACE_MCP_CONFIG, self.workspace))
612
+ files.append(_resolve_root(WORKSPACE_AGENTS_MCP_CONFIG, self.workspace))
613
+ if scope in ("global", "both"):
614
+ files.append((self.mcp_config or Path(DEFAULT_MCP_CONFIG)).expanduser().resolve())
615
+ if self.mcp_config is None:
616
+ files.append(_resolve_root(DEFAULT_AGENTS_MCP_CONFIG, self.workspace))
617
+ return tuple(dict.fromkeys(files))
618
+
619
+
620
+ def _default_theme_file() -> Path | None:
621
+ """Return the conventional palette file when the user has created one."""
622
+ return DEFAULT_THEME_FILE if DEFAULT_THEME_FILE.is_file() else None
623
+
624
+
625
+ def _without_client_route(base_url: str) -> str:
626
+ """Return ``base_url`` without a trailing route the client appends itself.
627
+
628
+ ``http://gw.test:8787/v1/messages`` is what a reader writes when they mean
629
+ the Messages route, and the client adds that part; keeping it would send
630
+ every request to ``…/v1/messages/v1/messages``, which a gateway answers with
631
+ a 404 for a path it does not serve — a message about the request that gives
632
+ no hint about the line of configuration behind it. Only the route goes: a
633
+ prefix in front of it is part of where the API lives and stays. A value that
634
+ is nothing but the route is left alone, having nothing in front of it to
635
+ keep, so the request fails where the reader can see it.
636
+ """
637
+ stripped = base_url.rstrip("/")
638
+ for route in CLIENT_ROUTES:
639
+ prefix = stripped[: -len(route)]
640
+ if stripped.endswith(route) and prefix:
641
+ return prefix
642
+ return base_url
643
+
644
+
645
+ def _typed(value: object, where: str, expected: type[T]) -> T:
646
+ """Return one value, rejecting a wrong type with a located message."""
647
+ if expected is int and isinstance(value, bool):
648
+ raise ValueError(f"{where} must be int")
649
+ if not isinstance(value, expected):
650
+ raise ValueError(f"{where} must be {expected.__name__}")
651
+ return value
652
+
653
+
654
+ def _number(value: object, where: str) -> float:
655
+ """Return an int-or-float setting as a float, rejecting a bool and anything else."""
656
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
657
+ raise ValueError(f"{where} must be a number")
658
+ return float(value)
659
+
660
+
661
+ def _resolve_root(root: str | Path, workspace: Path) -> Path:
662
+ """Return one configured directory: ``~`` expanded, a relative path against the workspace."""
663
+ expanded = Path(root).expanduser()
664
+ return (expanded if expanded.is_absolute() else workspace / expanded).resolve()
665
+
666
+
667
+ @overload
668
+ def _setting(data: dict[str, object], key: str, expected: type[T], default: T) -> T: ...
669
+
670
+
671
+ @overload
672
+ def _setting(data: dict[str, object], key: str, expected: type[T], default: None = None) -> T | None: ...
673
+
674
+
675
+ def _setting(data: dict[str, object], key: str, expected: type, default: object = None) -> object:
676
+ """Return one model setting, rejecting a wrong type."""
677
+ value = data.get(key, default)
678
+ if value is None:
679
+ return None
680
+ return _typed(value, f"Config key {key!r}", expected)
681
+
682
+
683
+ def _entry_model(entry: dict[str, object], index: int) -> ModelConfig:
684
+ """Build one model from a ``[[models]]`` table, rejecting unknown keys."""
685
+ label = f"Config models[{index}]"
686
+ unknown = sorted(set(entry) - MODEL_KEYS)
687
+ if unknown:
688
+ raise ValueError(f"Unknown config keys in {label}: {', '.join(unknown)}")
689
+ model = entry.get("model")
690
+ if model is None:
691
+ raise ValueError(f"{label} is missing 'model'")
692
+ anthropic = _setting(entry, "anthropic", bool, False)
693
+ # The conventional variable for the API this entry speaks, so an Anthropic
694
+ # model does not have to borrow a key named after OpenAI.
695
+ variable = "ANTHROPIC_API_KEY" if anthropic else "OPENAI_API_KEY"
696
+ token = entry.get("token") or os.getenv(variable, "")
697
+ try:
698
+ return ModelConfig(
699
+ model=_typed(model, f"{label} key 'model'", str),
700
+ token=_typed(token, f"{label} key 'token'", str),
701
+ display_model=_setting(entry, "display_model", str, None),
702
+ base_url=_setting(entry, "base_url", str, None),
703
+ anthropic=anthropic,
704
+ responses_api=_setting(entry, "responses_api", bool, False),
705
+ chat=_setting(entry, "chat", bool, False),
706
+ multimodal=_setting(entry, "multimodal", bool, False),
707
+ temperature=None
708
+ if entry.get("temperature") is None
709
+ else _number(entry["temperature"], f"{label} key 'temperature'"),
710
+ web_search=_setting(entry, "web_search", bool, False),
711
+ web_search_name=_setting(entry, "web_search_name", str, None),
712
+ context_window=_typed(
713
+ entry.get("context_window", DEFAULT_CONTEXT_WINDOW), f"{label} key 'context_window'", int
714
+ ),
715
+ compact_percent=_number(
716
+ entry.get("compact_percent", DEFAULT_COMPACT_PERCENT), f"{label} key 'compact_percent'"
717
+ ),
718
+ reasoning_efforts=_read_reasoning_efforts(
719
+ entry.get("reasoning_efforts"), f"{label} key 'reasoning_efforts'"
720
+ ),
721
+ )
722
+ except ValueError as error:
723
+ # The same model id can be configured twice against different endpoints,
724
+ # so which entry a rule refused has to be in the message.
725
+ raise ValueError(f"{label}: {error}") from error
726
+
727
+
728
+ def _reject_shared_display_names(models: tuple[ModelConfig, ...], source: Path) -> None:
729
+ """Refuse a configuration in which two models are shown by one name.
730
+
731
+ The name a model is chosen by is the one the picker shows — ``display_model``, or the model's own id when it has
732
+ none — and a session records it. Two entries under one name would make ``/model`` a guess, and the picker, the
733
+ status line and the session's memory all name it, so it is refused while the file is read rather than when a
734
+ reader first tries to use it.
735
+ """
736
+ seen: dict[str, int] = {}
737
+ shared: list[str] = []
738
+ for index, model in enumerate(models, start=1):
739
+ name = model.shown_name
740
+ if name in seen and name not in shared:
741
+ shared.append(name)
742
+ seen.setdefault(name, index)
743
+ if shared:
744
+ names = ", ".join(repr(name) for name in shared)
745
+ raise ValueError(
746
+ f"Two models share one name in {source}: {names}. A model is chosen by the name it is shown by, so give "
747
+ "each entry its own display_model"
748
+ )
749
+
750
+
751
+ def model_index(models: Sequence[ModelConfig], name: str) -> int | None:
752
+ """Return the place of the configured model a name means, or ``None`` when no model has it.
753
+
754
+ The name a reader sees — ``display_model``, or the model's own id — is looked for first, and the model's id only
755
+ when no display name answers. Each step must find one entry: a display name two entries share is refused when the
756
+ configuration is read, and an id two entries share is ambiguity this cannot resolve.
757
+
758
+ Raises:
759
+ ValueError: More than one model answers to the name.
760
+ """
761
+ shown = [index for index, model in enumerate(models) if model.shown_name == name]
762
+ matches = shown or [index for index, model in enumerate(models) if model.model == name]
763
+ if len(matches) > 1:
764
+ raise ValueError(f"Ambiguous model: {name}; use a unique display_model")
765
+ return matches[0] if matches else None
766
+
767
+
768
+ def _read_models(raw: object, source: Path, *, required: bool = True) -> tuple[ModelConfig, ...]:
769
+ """Build every configured model; ``required`` says whether a file that lists none is refused.
770
+
771
+ The terminal asks for ``required=False``: a fresh machine has no models, and the shell can set one up from
772
+ ``/config`` rather than refusing to start. What is left out is only the demand for one — an entry that is
773
+ there and wrong is still refused here, because it would fail on the first request otherwise.
774
+ """
775
+ if raw is None or raw == []:
776
+ if required:
777
+ raise ValueError(f"No models configured in {source}; add at least one [[models]] entry")
778
+ return ()
779
+ if not isinstance(raw, list):
780
+ raise ValueError("Config key 'models' must be an array of tables ([[models]])")
781
+ if any(not isinstance(entry, dict) for entry in raw):
782
+ raise ValueError("Config key 'models' must be an array of tables ([[models]])")
783
+ models = [_entry_model(entry, index) for index, entry in enumerate(raw, start=1)]
784
+ _reject_shared_display_names(tuple(models), source)
785
+ return tuple(models)
786
+
787
+
788
+ def read_models(path: str | Path | None = None) -> tuple[ModelConfig, ...]:
789
+ """Return the models a config file lists, and nothing else it holds.
790
+
791
+ Unlike :func:`load_config`, no models is an answer (the file is new, or a program is being set up) and a
792
+ missing file is an empty one. A model that is configured wrongly is still an error, because it would fail on
793
+ the first request otherwise.
794
+
795
+ Args:
796
+ path: The file to read; ``$ZETTCODE_CONFIG`` or ``~/.zettcode/config.toml`` by default.
797
+
798
+ Raises:
799
+ ValueError: When the path exists and is not a file, or when what it holds cannot be read.
800
+ """
801
+ source = check_config_path(path)
802
+ if not source.is_file():
803
+ return ()
804
+ try:
805
+ data = tomllib.loads(source.read_text(encoding="utf-8"))
806
+ except tomllib.TOMLDecodeError as error:
807
+ raise ValueError(f"Invalid config file {source}: {error}") from error
808
+ return _read_models(data.get("models"), source, required=False)
809
+
810
+
811
+ def _read_table(raw: object, key: str, source: Path, allowed: frozenset[str]) -> dict[str, object]:
812
+ """Return one optional config table, rejecting unknown keys and wrong shapes."""
813
+ if raw is None:
814
+ return {}
815
+ if not isinstance(raw, dict):
816
+ raise ValueError(f"Config key {key!r} must be a table ([{key}])")
817
+ unknown = sorted(set(raw) - allowed)
818
+ if unknown:
819
+ raise ValueError(f"Unknown config keys in the [{key}] table of {source}: {', '.join(unknown)}")
820
+ return raw
821
+
822
+
823
+ def _read_plugins(raw: object, source: Path) -> tuple[dict[str, object], dict[str, dict[str, object]]]:
824
+ """Split the ``[plugins]`` table into its own keys and the settings of each plugin.
825
+
826
+ ``[plugins]`` holds the switch (``enabled``, ``disable``) and, under any other name, a table of that plugin's own
827
+ keys (``[plugins.recap] after_seconds = 180``). The configuration cannot know which plugins will be installed and
828
+ which keys they read, so it keeps those values as written: a plugin declares their meaning and their default, and a
829
+ table no plugin claims is harmless.
830
+
831
+ Raises:
832
+ ValueError: The table, a plugin's table, or a value in one is not what it must be.
833
+ """
834
+ if raw is None:
835
+ return {}, {}
836
+ if not isinstance(raw, dict):
837
+ raise ValueError("Config key 'plugins' must be a table ([plugins])")
838
+ own = {key: value for key, value in raw.items() if key in PLUGIN_KEYS}
839
+ settings: dict[str, dict[str, object]] = {}
840
+ for name, table in raw.items():
841
+ if name in PLUGIN_KEYS:
842
+ continue
843
+ where = f"Config table '[plugins.{name}]' in {source}"
844
+ if not isinstance(table, dict):
845
+ raise ValueError(
846
+ f"{where} must be a table for that plugin's settings (the [plugins] table itself takes "
847
+ f"{', '.join(sorted(PLUGIN_KEYS))})"
848
+ )
849
+ values: dict[str, object] = {}
850
+ for key, value in table.items():
851
+ if isinstance(value, (dict, list)):
852
+ raise ValueError(f"{where} key {key!r} must be a string, a number or a boolean")
853
+ values[str(key)] = value
854
+ settings[str(name)] = values
855
+ return own, settings
856
+
857
+
858
+ def _read_reasoning_efforts(raw: object, where: str) -> tuple[ReasoningEffort, ...]:
859
+ """Return the levels one model accepts, cheapest first; none written means every level."""
860
+ if raw is None:
861
+ return ()
862
+ if not isinstance(raw, list) or any(not isinstance(entry, str) for entry in raw):
863
+ raise ValueError(f"{where} must be an array of level names")
864
+ known = {level.value: level for level in ReasoningEffort}
865
+ chosen: list[ReasoningEffort] = []
866
+ for entry in raw:
867
+ level = known.get(entry.strip().lower())
868
+ if level is None:
869
+ raise ValueError(f"{where} names {entry!r}, which is not one of {', '.join(known)}")
870
+ chosen.append(level)
871
+ if not chosen:
872
+ raise ValueError(f"{where} must name at least one level, or be left out for all of them")
873
+ return tuple(sorted(set(chosen), key=tuple(ReasoningEffort).index))
874
+
875
+
876
+ def _read_scope(raw: object, source: Path, table: str) -> str:
877
+ """Return a ``[skills]``/``[mcp]`` scope, rejecting a word that names none of the three."""
878
+ if raw is None:
879
+ return DEFAULT_SCOPE
880
+ if not isinstance(raw, str) or raw.strip().lower() not in SCOPES:
881
+ words = ", ".join(sorted(SCOPES))
882
+ raise ValueError(f"Config key '{table}.scope' in {source} must be one of {words}, not {raw!r}")
883
+ return raw.strip().lower()
884
+
885
+
886
+ def _read_skill_roots(raw: object, source: Path) -> tuple[Path, ...]:
887
+ """Return the extra skill directories, requiring strings in an array."""
888
+ if raw is None:
889
+ return ()
890
+ if not isinstance(raw, list) or any(not isinstance(entry, str) for entry in raw):
891
+ raise ValueError(f"Config key 'skills.roots' in {source} must be an array of strings")
892
+ return tuple(Path(entry) for entry in raw)
893
+
894
+
895
+ def _read_mcp_config(raw: object, source: Path) -> Path | None:
896
+ """Return the MCP server file the config points at, if it points at one."""
897
+ if raw is None:
898
+ return None
899
+ return Path(_typed(raw, f"Config key 'mcp.config' in {source}", str))
900
+
901
+
902
+ def read_disabled_plugins(path: Path | None = None) -> tuple[str, ...]:
903
+ """Return the plugins the reader switched off with ``/plugins disable``, as that file records them.
904
+
905
+ Read here, while the configuration is built, so everything downstream sees one list: the loaders never open a
906
+ file of their own, and a test that builds its own config is unaffected by the machine it runs on. A file that is
907
+ missing, unreadable or not a list of names disables nothing.
908
+ """
909
+ target = path if path is not None else DEFAULT_PLUGINS_FILE
910
+ try:
911
+ raw = json.loads(target.read_text(encoding="utf-8"))
912
+ except (OSError, ValueError):
913
+ return ()
914
+ names = raw.get("disabled") if isinstance(raw, dict) else None
915
+ if not isinstance(names, list):
916
+ return ()
917
+ return tuple(name.strip() for name in names if isinstance(name, str) and name.strip())
918
+
919
+
920
+ def _read_disabled_plugins(raw: object, source: Path) -> tuple[str, ...]:
921
+ """Return the entry-point names the ``[plugins]`` table switches off."""
922
+ if raw is None:
923
+ return ()
924
+ if not isinstance(raw, list) or any(not isinstance(entry, str) or not entry.strip() for entry in raw):
925
+ raise ValueError(f"Config key 'plugins.disable' in {source} must be an array of non-empty strings")
926
+ return tuple(entry.strip() for entry in raw)
927
+
928
+
929
+ def read_plugins_table(raw: object, source: Path) -> tuple[bool, tuple[str, ...], dict[str, dict[str, object]]]:
930
+ """Return ``[plugins]`` as ``(enabled, disable, settings)``: the one reading of that table.
931
+
932
+ The configuration and ``zettcode plugin`` both need it, and the second must not build the whole settings object
933
+ (that insists on a model). They used to each write the rules down, and the copies drifted: the command refused a
934
+ ``[plugins.recap]`` table the configuration accepts. Both call this now, so a file one reads, the other reads.
935
+
936
+ Args:
937
+ raw: What the file holds under ``plugins``; ``None`` when it has no such table.
938
+ source: The file, for messages.
939
+
940
+ Returns:
941
+ Whether installed plugins load at all, the entry-point names left out, and each plugin's own settings table by
942
+ name.
943
+
944
+ Raises:
945
+ ValueError: The table, a plugin's table, or a value in one is not what it must be.
946
+ """
947
+ own, settings = _read_plugins(raw, source)
948
+ enabled = _typed(own.get("enabled", True), f"Config key 'plugins.enabled' in {source}", bool)
949
+ return enabled, _read_disabled_plugins(own.get("disable"), source), settings
950
+
951
+
952
+ def _read_theme_name(raw: object, source: Path) -> str | None:
953
+ """Return the palette name ``[ui] theme`` asks for, rejecting what could not be typed after ``/theme``."""
954
+ return _read_word(raw, "ui.theme", "a palette name", source)
955
+
956
+
957
+ def _read_word(raw: object, key: str, what: str, source: Path) -> str | None:
958
+ """Return a name the reader typed into the config, rejecting what could not be typed after its command."""
959
+ if raw is None:
960
+ return None
961
+ if not isinstance(raw, str) or not raw.strip() or any(character.isspace() for character in raw.strip()):
962
+ raise ValueError(f"Config key '{key}' in {source} must be {what} without spaces")
963
+ return raw.strip()
964
+
965
+
966
+ def _read_pet_position(raw: object, source: Path) -> tuple[float, float] | None:
967
+ """Return where the pet was left, as two shares from 0 to 1, or ``None`` when the file says nothing."""
968
+ if raw is None:
969
+ return None
970
+ shares = raw if isinstance(raw, list) and len(raw) == 2 else None
971
+ if shares is None or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in shares):
972
+ raise ValueError(f"Config key 'ui.pet_position' in {source} must be two numbers from 0 to 1: [across, down]")
973
+ across, down = float(shares[0]), float(shares[1])
974
+ if not (0.0 <= across <= 1.0 and 0.0 <= down <= 1.0):
975
+ raise ValueError(f"Config key 'ui.pet_position' in {source} must be two numbers from 0 to 1: [across, down]")
976
+ return across, down
977
+
978
+
979
+ def load_config(
980
+ workspace: str | Path,
981
+ *,
982
+ path: str | Path | None = None,
983
+ reduced_motion_default: bool = False,
984
+ require_models: bool = True,
985
+ ) -> ZettCodeConfig:
986
+ """Build the runtime settings from the config file, falling back to defaults.
987
+
988
+ Args:
989
+ workspace: Directory the agent works in; the one value still passed on
990
+ the command line.
991
+ path: Config file to read; ``None`` uses ``$ZETTCODE_CONFIG`` or
992
+ ``~/.zettcode/config.toml``.
993
+ reduced_motion_default: Value for ``reduced_motion`` when the file does
994
+ not set it; the caller passes the terminal/environment detection.
995
+ require_models: Refuse a file that lists no models. The terminal passes
996
+ ``False``, so a machine that has never been set up reaches the shell
997
+ and its ``/config`` instead of an error; everything that has to run a
998
+ request still refuses to.
999
+
1000
+ Raises:
1001
+ ValueError: For a path that is not a file, an unreadable file, an unknown
1002
+ key, a wrong type, or a missing model when one is required.
1003
+ """
1004
+ source = check_config_path(path)
1005
+ data: dict[str, object] = {}
1006
+ if source.is_file():
1007
+ try:
1008
+ data = tomllib.loads(source.read_text(encoding="utf-8"))
1009
+ except tomllib.TOMLDecodeError as error:
1010
+ raise ValueError(f"Invalid config file {source}: {error}") from error
1011
+ unknown = sorted(set(data) - CONFIGURABLE)
1012
+ if unknown:
1013
+ raise ValueError(f"Unknown config keys in {source}: {', '.join(unknown)}")
1014
+
1015
+ agents_md = _read_table(data.get("agents_md"), "agents_md", source, AGENTS_MD_KEYS)
1016
+ ask_user = _read_table(data.get("ask_user"), "ask_user", source, ASK_USER_KEYS)
1017
+ skills = _read_table(data.get("skills"), "skills", source, SKILL_KEYS)
1018
+ mcp = _read_table(data.get("mcp"), "mcp", source, MCP_KEYS)
1019
+ plugins_enabled, disabled_plugins, plugin_settings = read_plugins_table(data.get("plugins"), source)
1020
+ transcript = _read_table(data.get("transcript"), "transcript", source, TRANSCRIPT_KEYS)
1021
+ update = _read_table(data.get("update"), "update", source, UPDATE_KEYS)
1022
+ harness = _read_table(data.get("harness"), "harness", source, HARNESS_KEYS)
1023
+ shell = _read_table(data.get("shell"), "shell", source, SHELL_KEYS)
1024
+ compaction = _read_table(data.get("compaction"), "compaction", source, COMPACTION_KEYS)
1025
+ sessions = _read_table(data.get("sessions"), "sessions", source, SESSIONS_KEYS)
1026
+ ui = _read_table(data.get("ui"), "ui", source, UI_KEYS)
1027
+ raw_statusline = data.get("statusline")
1028
+ # An item of the request line may be added by a plugin, which the config cannot know yet; its key is a switch
1029
+ # all the same, so any key with the prefix is taken, and one no plugin has is harmless.
1030
+ extra_parts = (
1031
+ frozenset(key for key in raw_statusline if isinstance(key, str) and key.startswith(TURN_PREFIX))
1032
+ if isinstance(raw_statusline, dict)
1033
+ else frozenset()
1034
+ )
1035
+ statusline = _read_table(raw_statusline, "statusline", source, STATUSLINE_KEYS | extra_parts)
1036
+
1037
+ return ZettCodeConfig(
1038
+ workspace=Path(workspace),
1039
+ models=_read_models(data.get("models"), source, required=require_models),
1040
+ theme_file=_default_theme_file(),
1041
+ theme=_read_theme_name(ui.get("theme"), source),
1042
+ pet=_read_word(ui.get("pet"), "ui.pet", "a pet name", source),
1043
+ pet_position=_read_pet_position(ui.get("pet_position"), source),
1044
+ reduced_motion=reduced_motion_default,
1045
+ transcript_max_entries=_typed(
1046
+ transcript.get("max_entries", DEFAULT_TRANSCRIPT_MAX_ENTRIES),
1047
+ f"Config key 'transcript.max_entries' in {source}",
1048
+ int,
1049
+ ),
1050
+ mouse_scroll_speed=_number(
1051
+ transcript.get("mouse_scroll_speed", DEFAULT_MOUSE_SCROLL_SPEED),
1052
+ f"Config key 'transcript.mouse_scroll_speed' in {source}",
1053
+ ),
1054
+ tips=bool(_setting(transcript, "tips", bool, True)),
1055
+ agents_md_enabled=bool(_setting(agents_md, "enabled", bool, True)),
1056
+ ask_user_enabled=bool(_setting(ask_user, "enabled", bool, True)),
1057
+ skills_enabled=bool(_setting(skills, "enabled", bool, True)),
1058
+ skill_roots=_read_skill_roots(skills.get("roots"), source),
1059
+ skills_scope=_read_scope(skills.get("scope"), source, "skills"),
1060
+ mcp_enabled=bool(_setting(mcp, "enabled", bool, True)),
1061
+ mcp_config=_read_mcp_config(mcp.get("config"), source),
1062
+ mcp_scope=_read_scope(mcp.get("scope"), source, "mcp"),
1063
+ self_compaction=bool(_setting(compaction, "self", bool, False)),
1064
+ statusline_hidden=tuple(
1065
+ name
1066
+ for name in (*STATUSLINE_NAMES, *sorted(extra_parts - STATUSLINE_KEYS))
1067
+ if not _typed(
1068
+ statusline.get(name, name not in OFF_BY_DEFAULT), f"Config key 'statusline.{name}' in {source}", bool
1069
+ )
1070
+ ),
1071
+ plugins_enabled=plugins_enabled,
1072
+ # The configuration file's own list. The legacy switch file's names are read where they are needed
1073
+ # (`zettcode.engine.plugins.switches`), so a switch can tell which list it may write.
1074
+ disabled_plugins=disabled_plugins,
1075
+ plugin_settings=plugin_settings,
1076
+ subagent_enabled=bool(_setting(harness, "subagent", bool, True)),
1077
+ subagent_max_iterations=_typed(
1078
+ harness.get("subagent_max_iterations", DEFAULT_SUBAGENT_MAX_ITERATIONS),
1079
+ f"Config key 'harness.subagent_max_iterations' in {source}",
1080
+ int,
1081
+ ),
1082
+ subagent_messaging=bool(_setting(harness, "subagent_messaging", bool, False)),
1083
+ todowrite_enabled=bool(_setting(harness, "todowrite", bool, False)),
1084
+ jit_harness=bool(_setting(harness, "jit", bool, False)),
1085
+ shell_network=bool(_setting(shell, "network", bool, False)),
1086
+ sessions_enabled=bool(_setting(sessions, "enabled", bool, True)),
1087
+ update_enabled=bool(_setting(update, "enabled", bool, True)),
1088
+ )