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
@@ -0,0 +1,457 @@
1
+ """Edit the ``[[models]]`` entries of the config file without disturbing the rest of it.
2
+
3
+ The file is the reader's: it has comments they wrote, tables this code has never heard of, and a
4
+ layout they chose. Rewriting it from parsed data would throw all of that away, so the edits here are
5
+ made on the *text* — one ``[[models]]`` block is added, replaced or removed — and everything outside
6
+ that block is left byte for byte as it was.
7
+
8
+ Because text editing is only safe when the text is what it looks like, every edit is checked before
9
+ it is written: the result must parse, must hold exactly the models the edit meant it to, and every
10
+ table other than ``models`` must be equal to what it was. A file that spells its models some other
11
+ way (an inline array, say) is refused rather than guessed at, and a refused or failed edit writes
12
+ nothing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import os
19
+ import re
20
+ import tempfile
21
+ from collections.abc import Mapping, Sequence
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from zettcode._compat import tomllib
26
+ from zettcode.config import MODEL_KEYS, ModelConfig, _read_models
27
+
28
+ #: A line that opens a table or an array of tables; the first thing on it, so a ``[`` inside a value
29
+ #: or a comment is not one.
30
+ _HEADER = re.compile(r"^\s*\[")
31
+ #: The line that opens one model.
32
+ _MODEL_HEADER = re.compile(r"^\s*\[\[\s*models\s*\]\]\s*(#.*)?$")
33
+
34
+ #: The order keys are written in, so a new entry reads the way the documentation shows one.
35
+ _KEY_ORDER = (
36
+ "display_model",
37
+ "model",
38
+ "base_url",
39
+ "token",
40
+ "chat",
41
+ "anthropic",
42
+ "responses_api",
43
+ "multimodal",
44
+ "temperature",
45
+ "web_search",
46
+ "web_search_name",
47
+ "context_window",
48
+ "compact_percent",
49
+ "reasoning_efforts",
50
+ )
51
+
52
+
53
+ class ConfigEditError(ValueError):
54
+ """The file could not be edited safely; its message says why and nothing was written."""
55
+
56
+
57
+ def toml_string(value: str) -> str:
58
+ """Return ``value`` as a TOML basic string, escaping what a basic string cannot hold."""
59
+ out = ['"']
60
+ for character in value:
61
+ code = ord(character)
62
+ if character == '"':
63
+ out.append('\\"')
64
+ elif character == "\\":
65
+ out.append("\\\\")
66
+ elif character == "\n":
67
+ out.append("\\n")
68
+ elif character == "\t":
69
+ out.append("\\t")
70
+ elif code < 0x20 or code == 0x7F:
71
+ out.append(f"\\u{code:04x}")
72
+ else:
73
+ out.append(character)
74
+ out.append('"')
75
+ return "".join(out)
76
+
77
+
78
+ def _value(value: object) -> str:
79
+ """Render one setting's value."""
80
+ if isinstance(value, bool):
81
+ return "true" if value else "false"
82
+ if isinstance(value, str):
83
+ return toml_string(value)
84
+ if isinstance(value, (int, float)):
85
+ return repr(value)
86
+ if isinstance(value, (list, tuple)) and all(isinstance(item, str) for item in value):
87
+ return "[" + ", ".join(toml_string(item) for item in value) + "]"
88
+ raise ConfigEditError(f"cannot write a value of type {type(value).__name__}")
89
+
90
+
91
+ def render_entry(entry: Mapping[str, object]) -> str:
92
+ """Return one ``[[models]]`` block for ``entry``, keys in the documented order.
93
+
94
+ Args:
95
+ entry: The keys of one model as they are written in the file.
96
+ """
97
+ unknown = sorted(set(entry) - MODEL_KEYS)
98
+ if unknown:
99
+ raise ConfigEditError(f"unknown model keys: {', '.join(unknown)}")
100
+ lines = ["[[models]]"]
101
+ for key in _KEY_ORDER:
102
+ if key in entry:
103
+ lines.append(f"{key} = {_value(entry[key])}")
104
+ return "\n".join(lines) + "\n"
105
+
106
+
107
+ def entry_from_model(model: ModelConfig, *, token: str | None = None) -> dict[str, object]:
108
+ """Return the file keys for ``model``, leaving out whatever is the default.
109
+
110
+ Args:
111
+ model: A validated model.
112
+ token: What to write for the token; ``None`` writes the one the model holds.
113
+ """
114
+ from zettcode.config import DEFAULT_COMPACT_PERCENT, DEFAULT_CONTEXT_WINDOW
115
+
116
+ written = model.token if token is None else token
117
+ entry: dict[str, object] = {"model": model.model}
118
+ if written:
119
+ entry["token"] = written
120
+ if model.display_model:
121
+ entry["display_model"] = model.display_model
122
+ if model.base_url:
123
+ entry["base_url"] = model.base_url
124
+ if model.anthropic:
125
+ entry["anthropic"] = True
126
+ if model.responses_api:
127
+ entry["responses_api"] = True
128
+ if model.chat:
129
+ entry["chat"] = True
130
+ if model.multimodal:
131
+ entry["multimodal"] = True
132
+ if model.temperature is not None:
133
+ entry["temperature"] = model.temperature
134
+ if model.web_search:
135
+ entry["web_search"] = True
136
+ if model.web_search_name:
137
+ entry["web_search_name"] = model.web_search_name
138
+ if model.context_window != DEFAULT_CONTEXT_WINDOW:
139
+ entry["context_window"] = model.context_window
140
+ if model.compact_percent != DEFAULT_COMPACT_PERCENT:
141
+ entry["compact_percent"] = model.compact_percent
142
+ if model.reasoning_efforts:
143
+ entry["reasoning_efforts"] = [level.value for level in model.reasoning_efforts]
144
+ return entry
145
+
146
+
147
+ def _blocks(lines: list[str]) -> list[tuple[int, int]]:
148
+ """Return the ``(start, end)`` line range of every ``[[models]]`` block, in order.
149
+
150
+ A block runs to the next table header, less the blank and comment lines at its end: those are
151
+ the introduction to whatever follows, and belong to it.
152
+ """
153
+ blocks: list[tuple[int, int]] = []
154
+ for index, line in enumerate(lines):
155
+ if not _MODEL_HEADER.match(line):
156
+ continue
157
+ end = len(lines)
158
+ for later in range(index + 1, len(lines)):
159
+ if _HEADER.match(lines[later]):
160
+ end = later
161
+ break
162
+ while end > index + 1 and (not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")):
163
+ end -= 1
164
+ blocks.append((index, end))
165
+ return blocks
166
+
167
+
168
+ def _parse(text: str, where: Path) -> dict[str, Any]:
169
+ try:
170
+ return tomllib.loads(text)
171
+ except tomllib.TOMLDecodeError as error:
172
+ raise ConfigEditError(f"{where} is not valid TOML: {error}") from error
173
+
174
+
175
+ def _guarded(text: str, where: Path) -> tuple[list[str], list[tuple[int, int]], dict[str, Any]]:
176
+ """Return the file's lines, its model blocks and its data, or refuse a layout this cannot edit.
177
+
178
+ A file with no ``[[models]]`` table at all is an empty list of models rather than a layout to refuse: that is
179
+ what a fresh machine has, and ``/config`` is how the first entry is written into it.
180
+ """
181
+ data = _parse(text, where)
182
+ declared = data.get("models") is not None
183
+ models = data.get("models")
184
+ if models is None:
185
+ models = []
186
+ data = {**data, "models": models}
187
+ if not isinstance(models, list) or not all(isinstance(entry, dict) for entry in models):
188
+ raise ConfigEditError(f"{where} has no [[models]] entries to edit")
189
+ lines = text.split("\n")
190
+ blocks = _blocks(lines)
191
+ if len(blocks) != len(models):
192
+ raise ConfigEditError(
193
+ f"{where} writes its models in a way /config cannot edit safely "
194
+ f"({len(models)} models, {len(blocks)} [[models]] blocks); edit the file by hand"
195
+ )
196
+ if declared and not blocks:
197
+ # `models = []`, or the key spelled some other way: this writes `[[models]]` blocks, and a second spelling
198
+ # of the same key cannot be added beside it — `models = []` plus one block is not TOML at all.
199
+ raise ConfigEditError(f"{where} writes 'models' as a value, which /config cannot edit; edit the file by hand")
200
+ return lines, blocks, data
201
+
202
+
203
+ def _checked(new_text: str, before: dict[str, Any], expected: list[dict[str, Any]], where: Path) -> None:
204
+ """Refuse a result that does not hold what the edit meant it to, before anything is written."""
205
+ after = _parse(new_text, where)
206
+ if after.get("models") != expected:
207
+ raise ConfigEditError("the edit did not produce the models it meant to; nothing was written")
208
+ others_before = {key: value for key, value in before.items() if key != "models"}
209
+ others_after = {key: value for key, value in after.items() if key != "models"}
210
+ if others_before != others_after:
211
+ raise ConfigEditError("the edit would have changed something other than the models; nothing was written")
212
+ models = after.get("models")
213
+ try:
214
+ _read_models(models, where)
215
+ except ValueError as error:
216
+ raise ConfigEditError(str(error)) from error
217
+
218
+
219
+ def add_model(text: str, entry: Mapping[str, object], *, where: Path) -> str:
220
+ """Return ``text`` with one more model after the last, which is where a reader looks for it.
221
+
222
+ Args:
223
+ text: The config file as it is.
224
+ entry: The new model's keys.
225
+ where: The file's path, for messages.
226
+ """
227
+ lines, blocks, data = _guarded(text, where)
228
+ block = render_entry(entry).rstrip("\n").split("\n")
229
+ expected = [*data["models"], dict(_parse(render_entry(entry), where)["models"][0])]
230
+ if blocks:
231
+ at = blocks[-1][1]
232
+ result = "\n".join([*lines[:at], "", *block, *lines[at:]])
233
+ else:
234
+ # Nothing to insert after: the entry goes at the end, set apart from whatever the file holds by one blank
235
+ # line, and a file that holds nothing gets no leading blank line.
236
+ head = text.rstrip()
237
+ body = "\n".join(block)
238
+ result = f"{head}\n\n{body}\n" if head else f"{body}\n"
239
+ _checked(result, data, expected, where)
240
+ return result
241
+
242
+
243
+ def replace_model(text: str, index: int, entry: Mapping[str, object], *, where: Path) -> str:
244
+ """Return ``text`` with model number ``index`` (from zero) rewritten as ``entry``.
245
+
246
+ Comments inside that one block are not kept; those above it and everywhere else are.
247
+ """
248
+ lines, blocks, data = _guarded(text, where)
249
+ if not 0 <= index < len(blocks):
250
+ raise ConfigEditError(f"there is no model number {index + 1}")
251
+ start, end = blocks[index]
252
+ new = [*lines[:start], *render_entry(entry).rstrip("\n").split("\n"), *lines[end:]]
253
+ expected = list(data["models"])
254
+ expected[index] = dict(_parse(render_entry(entry), where)["models"][0])
255
+ result = "\n".join(new)
256
+ _checked(result, data, expected, where)
257
+ return result
258
+
259
+
260
+ def remove_model(text: str, index: int, *, where: Path) -> str:
261
+ """Return ``text`` without model number ``index`` (from zero). The last one cannot be removed."""
262
+ lines, blocks, data = _guarded(text, where)
263
+ if not 0 <= index < len(blocks):
264
+ raise ConfigEditError(f"there is no model number {index + 1}")
265
+ if len(blocks) == 1:
266
+ raise ConfigEditError("that is the only model; add another before removing it")
267
+ start, end = blocks[index]
268
+ # The blank line that set the block apart goes with it, so removing leaves no gap behind.
269
+ if start > 0 and not lines[start - 1].strip():
270
+ start -= 1
271
+ new = [*lines[:start], *lines[end:]]
272
+ expected = list(data["models"])
273
+ del expected[index]
274
+ result = "\n".join(new)
275
+ _checked(result, data, expected, where)
276
+ return result
277
+
278
+
279
+ def _opened_by_subtables(lines: Sequence[str], table: str, key: str, value: object) -> bool:
280
+ """Return whether the file opens ``table`` only through sub-tables of it, with none of them called ``key``.
281
+
282
+ A file that writes ``[plugins.recap]`` has a ``plugins`` table the moment it parses, although no line ever says
283
+ ``[plugins]``; TOML lets a parent be declared after its children, so the key can still be written under a header
284
+ appended at the end. That holds only when the file does not assign the table itself, inline (``plugins = {...}``)
285
+ or through dotted keys (``plugins.disable = []``): a table assigned like that cannot be declared a second time.
286
+ A sub-table already called ``key`` (``[plugins.disable]``) cannot be written at all: the key and the table would
287
+ be one name declared twice. One case is refused although TOML would take it, because nobody has needed it and the
288
+ check after the edit is what keeps a wider rule honest: an entry of the table that is not a plain sub-table (an
289
+ array of tables, ``[[plugins.notes]]``). Anything refused is left for the reader to edit by hand.
290
+
291
+ This looks at lines, not at TOML, so it knows the spellings it was written for: a bare or quoted first segment.
292
+ A spelling it cannot see makes the appended header invalid, which the edit's own check catches and reports as a
293
+ file it cannot edit, so a miss costs a refusal and never a wrong file.
294
+ """
295
+ first = rf"(?:{re.escape(table)}|\"{re.escape(table)}\"|'{re.escape(table)}')"
296
+ assigned = re.compile(rf"^\s*{first}\s*[.=]")
297
+ if any(assigned.match(item) for item in lines):
298
+ return False
299
+ if not isinstance(value, dict) or not value or key in value:
300
+ return False
301
+ return all(isinstance(item, dict) for item in value.values())
302
+
303
+
304
+ def _set_key(text: str, table: str, key: str, literal: str, value: object, *, where: Path, command: str) -> str:
305
+ """Return ``text`` with ``[table] key`` set to ``value``, touching nothing else.
306
+
307
+ The key's line is replaced where the file already has one, added under an existing header, or the
308
+ table is appended at the end. The result is checked before it is returned: it must parse and differ
309
+ from the original in nothing but this key. A file that spells the table some other way (an inline
310
+ table, say) is refused rather than guessed at; a table it opens only through a sub-table of it, as
311
+ ``[plugins.recap]`` opens ``plugins``, gets a header of its own appended (see
312
+ :func:`_opened_by_subtables`).
313
+
314
+ Args:
315
+ table: The table's name (``"ui"``).
316
+ key: The key in it (``"theme"``).
317
+ literal: ``value`` as TOML spells it (``'"nord"'``, ``"true"``).
318
+ value: What the key must read back as.
319
+ where: The file, for messages.
320
+ command: What the reader ran, for the message when the file cannot be edited safely.
321
+ """
322
+ header_pattern = re.compile(rf"^\s*\[\s*{re.escape(table)}\s*\]\s*(#.*)?$")
323
+ key_pattern = re.compile(rf"^\s*{re.escape(key)}\s*=")
324
+ before = _parse(text, where)
325
+ lines = text.split("\n")
326
+ line = f"{key} = {literal}"
327
+ header = next((index for index, item in enumerate(lines) if header_pattern.match(item)), None)
328
+ if header is None:
329
+ if table in before and not _opened_by_subtables(lines, table, key, before[table]):
330
+ raise ConfigEditError(
331
+ f"{where} writes [{table}] in a way {command} cannot edit safely; edit the file by hand"
332
+ )
333
+ while lines and not lines[-1].strip():
334
+ lines.pop()
335
+ new = [*lines, "", f"[{table}]", line, ""]
336
+ else:
337
+ end = next((i for i in range(header + 1, len(lines)) if _HEADER.match(lines[i])), len(lines))
338
+ existing = next((i for i in range(header + 1, end) if key_pattern.match(lines[i])), None)
339
+ if existing is not None:
340
+ new = [*lines[:existing], line, *lines[existing + 1 :]]
341
+ else:
342
+ new = [*lines[: header + 1], line, *lines[header + 1 :]]
343
+ result = "\n".join(new)
344
+ try:
345
+ after = _parse(result, where)
346
+ except ConfigEditError as error:
347
+ # ``text`` parsed above, so it is this edit that made the file unreadable, not the reader: a table spelled in a
348
+ # way the line patterns above do not see (a quoted ``"plugins"``, say) is one the edit cannot make room in.
349
+ raise ConfigEditError(
350
+ f"{where} writes [{table}] in a way {command} cannot edit safely; edit the file by hand"
351
+ ) from error
352
+ table_before = {name: item for name, item in before.get(table, {}).items() if name != key}
353
+ table_after = {name: item for name, item in after.get(table, {}).items() if name != key}
354
+ others_before = {name: item for name, item in before.items() if name != table}
355
+ others_after = {name: item for name, item in after.items() if name != table}
356
+ if after.get(table, {}).get(key) != value or table_before != table_after or others_before != others_after:
357
+ raise ConfigEditError(f"the edit would have changed something other than {table}.{key}; nothing was written")
358
+ return result
359
+
360
+
361
+ def set_theme(text: str, name: str, *, where: Path) -> str:
362
+ """Return ``text`` with ``[ui] theme`` set to ``name``, touching nothing else.
363
+
364
+ Like the model edits it is checked before it is returned: the result must parse and differ from the
365
+ original in nothing but ``ui.theme``.
366
+ """
367
+ return _set_key(text, "ui", "theme", toml_string(name), name, where=where, command="/theme")
368
+
369
+
370
+ def set_pet(text: str, name: str, *, where: Path) -> str:
371
+ """Return ``text`` with ``[ui] pet`` set to ``name``, touching nothing else."""
372
+ return _set_key(text, "ui", "pet", toml_string(name), name, where=where, command="/pet")
373
+
374
+
375
+ def set_pet_position(text: str, across: float, down: float, *, where: Path) -> str:
376
+ """Return ``text`` with ``[ui] pet_position`` set to where the pet was left, touching nothing else.
377
+
378
+ The shares are rounded to three places: a pet moves a cell at a time, and a number such as
379
+ ``0.8571428571428571`` in a file the reader reads and edits is noise.
380
+ """
381
+ shares = [round(min(1.0, max(0.0, across)), 3), round(min(1.0, max(0.0, down)), 3)]
382
+ literal = "[" + ", ".join(repr(share) for share in shares) + "]"
383
+ return _set_key(text, "ui", "pet_position", literal, shares, where=where, command="the pet")
384
+
385
+
386
+ def set_self_compaction(text: str, enabled: bool, *, where: Path) -> str:
387
+ """Return ``text`` with ``[compaction] self`` set to ``enabled``, touching nothing else."""
388
+ return _set_key(
389
+ text, "compaction", "self", "true" if enabled else "false", enabled, where=where, command="/plugins"
390
+ )
391
+
392
+
393
+ def set_string_list(text: str, table: str, key: str, values: Sequence[str], *, where: Path, command: str) -> str:
394
+ """Return ``text`` with ``[table] key`` set to an array of strings, touching nothing else.
395
+
396
+ The array is written on one line, in the order given; the result is checked the way every other key edit is.
397
+ """
398
+ literal = "[" + ", ".join(toml_string(value) for value in values) + "]"
399
+ return _set_key(text, table, key, literal, list(values), where=where, command=command)
400
+
401
+
402
+ def set_boolean(text: str, table: str, key: str, enabled: bool, *, where: Path, command: str) -> str:
403
+ """Return ``text`` with ``[table] key`` set to a boolean, touching nothing else.
404
+
405
+ One key of one table, written and checked the way every other single-key edit is: a file that cannot take it is
406
+ refused whole rather than half-written.
407
+
408
+ Args:
409
+ text: The configuration as it is now.
410
+ table: The table's name (``"skills"``).
411
+ key: The key in it (``"enabled"``).
412
+ enabled: What it should read back as.
413
+ where: The file, for messages.
414
+ command: What the reader pressed, for the message when the file cannot be edited safely.
415
+ """
416
+ return _set_key(text, table, key, "true" if enabled else "false", enabled, where=where, command=command)
417
+
418
+
419
+ def set_statusline(text: str, shown: Mapping[str, bool], *, where: Path) -> str:
420
+ """Return ``text`` with each named part of ``[statusline]`` set to shown or hidden, touching nothing else.
421
+
422
+ A part is a key of the table (``ctx = false`` hides it, ``ctx = true`` shows it again). Each key is
423
+ written and checked on its own, as the other single-key edits are, so a file that cannot take one is
424
+ refused whole rather than half-written.
425
+ """
426
+ for name, visible in shown.items():
427
+ text = _set_key(
428
+ text, "statusline", name, "true" if visible else "false", visible, where=where, command="/statusline"
429
+ )
430
+ return text
431
+
432
+
433
+ def file_entries(text: str, *, where: Path) -> list[dict[str, Any]]:
434
+ """Return the models as the file writes them (a token left to the environment stays absent); none is a list."""
435
+ return list(_guarded(text, where)[2].get("models") or [])
436
+
437
+
438
+ def write_text_atomically(path: Path, text: str) -> None:
439
+ """Replace ``path`` with ``text`` so a crash leaves the old file or the new one, never half.
440
+
441
+ The new file takes the old one's permissions: the file holds tokens, and a reader who made it
442
+ private has not asked for that to change by adding a model.
443
+ """
444
+ path = path.expanduser()
445
+ path.parent.mkdir(parents=True, exist_ok=True)
446
+ mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
447
+ handle, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
448
+ try:
449
+ with os.fdopen(handle, "w", encoding="utf-8", newline="") as file:
450
+ file.write(text)
451
+ with contextlib.suppress(OSError): # not every file system keeps modes
452
+ os.chmod(name, mode)
453
+ os.replace(name, path)
454
+ except BaseException:
455
+ with contextlib.suppress(OSError):
456
+ os.unlink(name)
457
+ raise
@@ -0,0 +1,39 @@
1
+ """The engine: the one agent core that the terminal and the SDK both drive.
2
+
3
+ Most programs want :mod:`zettcode.sdk`, which is the documented, stable surface. The engine is here for the ones that
4
+ need the core itself, and it stays small on purpose; see ``docs/internal/engine.md`` and
5
+ ``docs/internal/namespace-split.md``. It imports nothing else of ours: the plugin set it runs, the line to the other
6
+ sessions and the MCP client are the SDK's, passed in by whoever builds the runtime. Names load on first use, so
7
+ importing one submodule does not build the rest.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ from typing import TYPE_CHECKING
14
+
15
+ #: Where each public name lives; read by :func:`__getattr__` on first use.
16
+ _EXPORTS = {
17
+ "ZettCodeEngine": ".core",
18
+ }
19
+
20
+
21
+ if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
22
+ from .core import ZettCodeEngine
23
+
24
+
25
+ def __getattr__(name: str) -> object:
26
+ """Import the module a public name lives in, the first time it is asked for."""
27
+ try:
28
+ module = _EXPORTS[name]
29
+ except KeyError:
30
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
31
+ return getattr(importlib.import_module(module, __name__), name)
32
+
33
+
34
+ def __dir__() -> list[str]:
35
+ """Return the names this package publishes."""
36
+ return sorted(__all__)
37
+
38
+
39
+ __all__ = ["ZettCodeEngine"]
@@ -0,0 +1,81 @@
1
+ """The agent: one class, assembled from the parts that make it up.
2
+
3
+ The class used to be one 750-line body doing eight unrelated jobs. It is divided by what each part is responsible for:
4
+
5
+ ============== ===========================================================================
6
+ ``base`` :class:`AgentBase` — the engine, the workspace, the active session id
7
+ ``settings`` :class:`SettingsMixin` — model, reasoning effort, shell permission
8
+ ``context`` :class:`ContextMixin` — the request the next turn carries; compaction
9
+ ``sessions`` :class:`SessionsMixin` — switch, resume, fork, list, and title sessions
10
+ ``turns`` :class:`TurnsMixin` — build a request, stream its events, steer it
11
+ ``interaction`` :class:`InteractionMixin` — answer an approval request or a question
12
+ ``exports`` :class:`ExportsMixin` — write a session out as a page
13
+ ``prompt`` the parts of a user turn: text, ``@`` references, images (no class)
14
+ ============== ===========================================================================
15
+
16
+ A mixin inherits the ones whose methods it really calls, and :class:`AgentBase` below all of them, so each is
17
+ type-checked against what it relies on; nothing is declared twice. This class is the composition and the
18
+ lifecycle — building the engine, and closing it. It knows nothing of the terminal: the terminal's commands and
19
+ preferences are a subclass of its own (``zettcode.app.agent.agent.TerminalAgent``).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TypeVar
25
+
26
+ from zettcode.config import ZettCodeConfig
27
+ from zettcode.engine.core import PluginLoader, ZettCodeEngine
28
+
29
+ from .context import ContextMixin
30
+ from .exports import ExportsMixin
31
+ from .interaction import InteractionMixin
32
+ from .sessions import SessionsMixin
33
+ from .settings import SettingsMixin
34
+ from .turns import TurnsMixin
35
+
36
+ __all__ = ["ZettCodeAgent"]
37
+
38
+ AgentT = TypeVar("AgentT", bound="ZettCodeAgent")
39
+
40
+
41
+ class ZettCodeAgent(
42
+ InteractionMixin,
43
+ TurnsMixin,
44
+ ExportsMixin,
45
+ SessionsMixin,
46
+ SettingsMixin,
47
+ ContextMixin,
48
+ ):
49
+ """Expose agent operations without leaking engine resources to the front end.
50
+
51
+ The engine assembles the vendor client and extensions; this class is the entry point for turns, session and
52
+ model selection, progress, and responses to approval requests.
53
+ """
54
+
55
+ def __init__(self, runtime: ZettCodeEngine) -> None:
56
+ self.runtime = runtime
57
+
58
+ @classmethod
59
+ async def create(cls: type[AgentT], config: ZettCodeConfig, load: PluginLoader) -> AgentT:
60
+ """Build the engine with the plugin set the caller chose, and wrap it in the agent.
61
+
62
+ The loader is required: the engine ships no built-in plugin, so a runtime built without one has no tools, no
63
+ session store and no approval — and the front ends that know which agent they want are the SDK
64
+ (:func:`zettcode.sdk.builtins.load_agent_plugins`) and the terminal
65
+ (:func:`zettcode.app.plugins.loader.terminal_plugins`).
66
+ """
67
+ return cls(await ZettCodeEngine.create(config, load))
68
+
69
+ @classmethod
70
+ def preview(cls: type[AgentT], config: ZettCodeConfig, load: PluginLoader) -> AgentT:
71
+ """Wrap an engine that still has to start, so a front end can paint first.
72
+
73
+ The engine here holds the session and the extensions the loader's plugins contribute; the provider SDK and
74
+ the client are built by the first :meth:`runtime.start`, which a turn or a command awaits. Everything a front
75
+ end reads per frame — the model, the effort, the session, the usage — is answered from that preview.
76
+ """
77
+ return cls(ZettCodeEngine.preview(config, load))
78
+
79
+ async def aclose(self) -> None:
80
+ """Release the underlying engine resources."""
81
+ await self.runtime.aclose()
@@ -0,0 +1,60 @@
1
+ """What every part of the agent shares: the runtime it speaks to, and who and where it is.
2
+
3
+ The mixins in this package are split by what they are responsible for, and each one
4
+ inherits this class only to be type-checked against what the others rely on. The two
5
+ properties here are read by nearly all of them, which is why they live below all of them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from zettcode.engine.core import ZettCodeEngine
13
+ from zettcode.engine.storage import SessionLocked
14
+ from zettcode.mention import MentionProvider
15
+
16
+
17
+ class AgentBase:
18
+ """The runtime, the workspace it works in, and the session the next turn belongs to."""
19
+
20
+ #: The runtime this agent speaks to; the one thing the application agent holds.
21
+ runtime: ZettCodeEngine
22
+
23
+ @property
24
+ def workspace(self) -> Path:
25
+ """Return the workspace displayed in the header."""
26
+ return self.runtime.config.workspace
27
+
28
+ @property
29
+ def session_id(self) -> str:
30
+ """Return the session used for the next turn."""
31
+ return self.runtime.session_id
32
+
33
+ @property
34
+ def plugin_mentions(self) -> tuple[MentionProvider, ...]:
35
+ """Return the ``@`` resource providers the installed plugins registered."""
36
+ return self.runtime.plugins.mentions
37
+
38
+ @property
39
+ def session_locked(self) -> bool:
40
+ """Return whether another process has the active session open, so that this one may only read it.
41
+
42
+ Every session is locked by the process that has it open (see :mod:`..storage.locks`). A session this
43
+ process started is always its own; one it resumed may be somebody else's, and then it is shown and
44
+ nothing is written to it: no turn, no title, no compaction.
45
+ """
46
+ return self.runtime.session_locked
47
+
48
+ def retake_session(self) -> bool:
49
+ """Try again to lock the active session; return whether this process holds it now."""
50
+ return self.runtime.retake_session()
51
+
52
+ def require_session(self) -> None:
53
+ """Refuse to go on when the active session is another process's.
54
+
55
+ Raises:
56
+ SessionLocked: The lock is not held. A ``ValueError``, which the shell shows as a line in the
57
+ conversation.
58
+ """
59
+ if self.session_locked:
60
+ raise SessionLocked()