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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- 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
|
+
)
|