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,373 @@
1
+ """The conversation log: its JSONL record union and the parsed read model.
2
+
3
+ Records are a tree, not a list. Every record names the node it continues from::
4
+
5
+ m1 ── m2 ── m3 ── m4 ── m5 <- the head is the last message written
6
+ └── C (a checkpoint through m3)
7
+
8
+ * The **head** is the last message line in the file. Appending continues from
9
+ it, and a *fork* is therefore a plain append: write a message whose ``parent``
10
+ is an older node and the abandoned branch stays in the file, out of context.
11
+ * A **checkpoint** is an annotation rather than a conversation node: its
12
+ ``parent`` is the last message it summarizes. The summary applies to any
13
+ branch that still contains that boundary, and a checkpoint never becomes the
14
+ parent of later messages.
15
+ * The **active branch** is the chain from the head back to the first message.
16
+ Its context is the newest checkpoint whose boundary sits on that chain, plus
17
+ the messages after it (see :meth:`Session.active`).
18
+
19
+ Message bodies are the runtime's own types: ``AnyMessage``, ``MessageTiming``,
20
+ and ``ModelUsage`` are imported from ``zett-agent`` rather than restated, and
21
+ the ``{"kind": ..., "data": ...}`` envelope is the runtime's own
22
+ ``encode_messages``/``decode_messages`` pair, called at this module's edge.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from collections.abc import Iterator, Sequence
30
+ from dataclasses import dataclass
31
+ from datetime import datetime, timezone
32
+ from typing import Annotated, Any, Literal, TypeAlias, cast, overload
33
+
34
+ from pydantic import BaseModel, Field, GetCoreSchemaHandler, TypeAdapter, field_serializer, field_validator
35
+ from pydantic_core import core_schema
36
+ from zett_agent.extensions.compaction import CompactedMessage
37
+ from zett_agent.extensions.events import MessageTiming
38
+ from zett_agent.messages import AnyMessage
39
+ from zett_agent.model import ModelUsage
40
+
41
+ from zettcode._compat import StrEnum
42
+
43
+ STORE_FILE = "data.jsonl"
44
+ STORE_VERSION = 1
45
+ MAX_AGENT_NAME = 64
46
+
47
+ #: Appended to a title when a session is forked from one that has a name, so a
48
+ #: session list shows where the fork came from without a second naming pass.
49
+ FORK_SUFFIX = " (fork)"
50
+
51
+ # Session ids become directory names, so they may not escape the store root.
52
+ SAFE_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
53
+
54
+ #: A JSON value, as the stored metadata and tags can contain. Containers hold
55
+ #: ``Any`` rather than naming this alias again: a recursive alias needs the
56
+ #: ``type`` statement, and pydantic only builds a schema for that spelling,
57
+ #: which Python 3.10 does not have.
58
+ JsonValue: TypeAlias = str | int | float | bool | None | list[Any] | dict[str, Any]
59
+
60
+
61
+ def now() -> datetime:
62
+ """Return the current UTC time, so every writer stamps records the same way."""
63
+ return datetime.now(timezone.utc)
64
+
65
+
66
+ class LineKind(StrEnum):
67
+ """Discriminator values of the record union, written as ``kind``."""
68
+
69
+ SESSION = "session"
70
+ MESSAGE = "message"
71
+ COMPACTION = "compaction"
72
+
73
+
74
+ def _load_messages(value: object) -> object:
75
+ """Decode stored ``{"kind", "data"}`` envelopes into runtime messages.
76
+
77
+ The runtime's decoder owns the kind-to-type union, so this module never
78
+ restates the message schema. A value that already holds decoded messages —
79
+ when a caller re-validates a line this process just built — passes through.
80
+
81
+ The import is inside the call on purpose: the runtime's storage module
82
+ builds on SQLAlchemy, and a session that is only being listed should not pay
83
+ for the engine that writes one.
84
+ """
85
+ from zett_agent.storage import decode_messages
86
+
87
+ if isinstance(value, (tuple, list)) and (not value or not isinstance(value[0], dict)):
88
+ return value
89
+ return tuple(decode_messages(json.dumps(list(cast("Sequence[object]", value)))))
90
+
91
+
92
+ def _dump_messages(messages: Sequence[AnyMessage]) -> list[dict[str, object]]:
93
+ """Encode runtime messages into the stored envelope form.
94
+
95
+ Imported here for the same reason as :func:`_load_messages`.
96
+ """
97
+ from zett_agent.storage import encode_messages
98
+
99
+ return json.loads(encode_messages(list(messages)))
100
+
101
+
102
+ class SessionLine(BaseModel):
103
+ """The first line of a session file: identity and origin."""
104
+
105
+ kind: Literal[LineKind.SESSION] = LineKind.SESSION
106
+ version: int
107
+ session_id: str
108
+ parent_session_id: str | None = None
109
+ agent_name: str | None = None
110
+ created_at: datetime
111
+
112
+
113
+ class Messages(Sequence[AnyMessage]):
114
+ """The messages of one stored line, turned into runtime messages when first read.
115
+
116
+ Reading a session parses every line, and decoding a message into its runtime type
117
+ is most of what that costs — it is the one step that runs Python for each message.
118
+ A reader that wants the whole branch pays it, but resuming a long session shows only
119
+ the newest part of it and totals the usage of the rest, which lives beside the
120
+ message and not in it. So a line read from the file keeps its envelopes as they
121
+ were parsed and decodes them the first time anything looks inside, and a line that
122
+ is never looked inside is never decoded.
123
+
124
+ It is a sequence of messages and nothing else: indexing, iterating, ``len``, and
125
+ equality all behave as the tuple it replaces did, and a line built from live
126
+ messages holds them already decoded. Writing a line that was never decoded writes
127
+ its envelopes back as they came.
128
+
129
+ The decode is idempotent and the result is assigned in one step, so two threads
130
+ that look inside at once both decode and the later assignment is the same value.
131
+ """
132
+
133
+ __slots__ = ("_decoded", "_raw")
134
+
135
+ def __init__(self, *, raw: Sequence[object] | None = None, decoded: tuple[AnyMessage, ...] | None = None) -> None:
136
+ """Hold stored envelopes to decode later, or messages that are already decoded."""
137
+ self._raw = raw
138
+ self._decoded = decoded
139
+
140
+ @classmethod
141
+ def of(cls, value: object, *, lazy: bool = True) -> Messages:
142
+ """Accept what the file holds (envelopes) or what a writer holds (messages).
143
+
144
+ Args:
145
+ value: Envelopes as parsed from JSON, runtime messages, or a ``Messages``.
146
+ lazy: Keep envelopes undecoded until they are read. Only a line parsed
147
+ from the file is lazy: one built in Python is decoded at once, so a
148
+ writer that hands over a malformed envelope is told when it does,
149
+ before anything is written.
150
+ """
151
+ if isinstance(value, Messages):
152
+ return value
153
+ items = cast("Sequence[object]", value)
154
+ if not items or not isinstance(items[0], dict):
155
+ return cls(decoded=tuple(cast("Sequence[AnyMessage]", items)))
156
+ messages = cls(raw=items)
157
+ if not lazy:
158
+ messages._messages()
159
+ return messages
160
+
161
+ @classmethod
162
+ def _validate(cls, value: object, info: core_schema.ValidationInfo) -> Messages:
163
+ """Validate for pydantic: lazily when parsing the file, eagerly for a Python caller."""
164
+ return cls.of(value, lazy=info.mode == "json")
165
+
166
+ @property
167
+ def decoded(self) -> bool:
168
+ """Return whether the messages have been turned into runtime types yet."""
169
+ return self._decoded is not None
170
+
171
+ def _messages(self) -> tuple[AnyMessage, ...]:
172
+ """Return the runtime messages, decoding the envelopes the first time."""
173
+ decoded = self._decoded
174
+ if decoded is None:
175
+ decoded = tuple(cast("Sequence[AnyMessage]", _load_messages(self._raw)))
176
+ self._decoded = decoded
177
+ self._raw = None
178
+ return decoded
179
+
180
+ def envelopes(self) -> list[dict[str, object]]:
181
+ """Return the stored form: the envelopes as read, or the messages encoded."""
182
+ raw = self._raw
183
+ if raw is not None:
184
+ return cast("list[dict[str, object]]", list(raw))
185
+ return _dump_messages(self._messages())
186
+
187
+ @overload
188
+ def __getitem__(self, index: int) -> AnyMessage: ...
189
+
190
+ @overload
191
+ def __getitem__(self, index: slice) -> tuple[AnyMessage, ...]: ...
192
+
193
+ def __getitem__(self, index: int | slice) -> AnyMessage | tuple[AnyMessage, ...]:
194
+ return self._messages()[index]
195
+
196
+ def __len__(self) -> int:
197
+ raw = self._raw
198
+ return len(raw) if raw is not None else len(self._messages())
199
+
200
+ def __iter__(self) -> Iterator[AnyMessage]:
201
+ return iter(self._messages())
202
+
203
+ def __eq__(self, other: object) -> bool:
204
+ if isinstance(other, Messages):
205
+ return self._messages() == other._messages()
206
+ if isinstance(other, tuple):
207
+ return self._messages() == other
208
+ return NotImplemented
209
+
210
+ __hash__ = None # type: ignore[assignment]
211
+
212
+ def __repr__(self) -> str:
213
+ return f"Messages({len(self)} {'decoded' if self.decoded else 'stored'})"
214
+
215
+ @classmethod
216
+ def __get_pydantic_core_schema__(cls, source: object, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
217
+ """Teach pydantic to read and write this as the stored envelope list."""
218
+ return core_schema.with_info_plain_validator_function(
219
+ cls._validate,
220
+ serialization=core_schema.plain_serializer_function_ser_schema(cls.envelopes, when_used="json"),
221
+ )
222
+
223
+
224
+ class MessageLine(BaseModel):
225
+ """One message, continuing from ``parent``."""
226
+
227
+ kind: Literal[LineKind.MESSAGE] = LineKind.MESSAGE
228
+ id: str
229
+ parent: str | None = None
230
+ session_id: str
231
+ request_id: str
232
+ message: Messages
233
+ timing: MessageTiming
234
+ usage: ModelUsage | None = None
235
+ metadata: dict[str, JsonValue] = Field(default_factory=dict)
236
+ tags: dict[str, JsonValue] = Field(default_factory=dict)
237
+ created_at: datetime
238
+
239
+
240
+ class CompactionLine(BaseModel):
241
+ """One checkpoint summarizing the branch up to ``parent``."""
242
+
243
+ kind: Literal[LineKind.COMPACTION] = LineKind.COMPACTION
244
+ id: str
245
+ parent: str
246
+ version: int
247
+ message: tuple[CompactedMessage, ...]
248
+ created_at: datetime
249
+
250
+ @field_validator("message", mode="before")
251
+ @classmethod
252
+ def _messages_from_storage(cls, value: object) -> object:
253
+ """Read the stored summary envelope back into a typed checkpoint body."""
254
+ return _load_messages(value)
255
+
256
+ @field_serializer("message", when_used="json")
257
+ def _messages_to_storage(self, value: Sequence[AnyMessage]) -> list[dict[str, object]]:
258
+ """Write the typed summary as the stored envelope list."""
259
+ return _dump_messages(value)
260
+
261
+
262
+ Line: TypeAlias = Annotated[SessionLine | MessageLine | CompactionLine, Field(discriminator="kind")]
263
+ # `TypeAdapter` takes a `TypeForm`, which mypy does not recognise in an `Annotated` union alias; the declared type
264
+ # on the left is what callers get.
265
+ LINES: TypeAdapter[SessionLine | MessageLine | CompactionLine] = TypeAdapter(cast(Any, Line))
266
+
267
+
268
+ @dataclass(frozen=True, slots=True)
269
+ class Session:
270
+ """One parsed session file: its header, every record, and the active branch.
271
+
272
+ Attributes:
273
+ header: The session line, or ``None`` for a file that has none yet.
274
+ messages: Every message line in file order, across all branches.
275
+ compactions: Every checkpoint in file order.
276
+ head_id: Id of the last message written, which the next append continues
277
+ from. A tree can have several leaves, so "the head" is a choice and
278
+ this is the store's: the most recent append wins, which is what
279
+ makes a fork a plain append.
280
+ """
281
+
282
+ header: SessionLine | None
283
+ messages: tuple[MessageLine, ...]
284
+ compactions: tuple[CompactionLine, ...]
285
+ head_id: str | None
286
+
287
+ @property
288
+ def session_id(self) -> str:
289
+ """Return the session id; every listed session has a header."""
290
+ assert self.header is not None
291
+ return self.header.session_id
292
+
293
+ @property
294
+ def parent_session_id(self) -> str | None:
295
+ """Return the conversation this one was delegated from, if any."""
296
+ return self.header.parent_session_id if self.header is not None else None
297
+
298
+ @property
299
+ def agent_name(self) -> str | None:
300
+ """Return the agent profile that owns the session, when set."""
301
+ return self.header.agent_name if self.header is not None else None
302
+
303
+ @property
304
+ def message_count(self) -> int:
305
+ """Return the number of stored messages, ignoring checkpoints."""
306
+ return len(self.messages)
307
+
308
+ def message(self, message_id: str) -> MessageLine | None:
309
+ """Return one message by id."""
310
+ return next((line for line in self.messages if line.id == message_id), None)
311
+
312
+ def index(self, message_id: str) -> int | None:
313
+ """Return a message's position in the file, or None when it is absent."""
314
+ return next((index for index, line in enumerate(self.messages) if line.id == message_id), None)
315
+
316
+ def branch(self) -> tuple[MessageLine, ...]:
317
+ """Return the active branch, oldest first: the head and its ancestors.
318
+
319
+ A fork leaves the abandoned branch in the file; this walk is what keeps
320
+ the fork out of the active context.
321
+ """
322
+ by_id = {line.id: line for line in self.messages}
323
+ branch: list[MessageLine] = []
324
+ current = self.head_id
325
+ while current is not None:
326
+ line = by_id.get(current)
327
+ if line is None:
328
+ break
329
+ branch.append(line)
330
+ current = line.parent
331
+ branch.reverse()
332
+ return tuple(branch)
333
+
334
+ def checkpoint_for(self, branch: tuple[MessageLine, ...] | None = None) -> CompactionLine | None:
335
+ """Return the newest checkpoint whose boundary the branch still contains."""
336
+ on_branch = {line.id for line in (self.branch() if branch is None else branch)}
337
+ applicable = [line for line in self.compactions if line.parent in on_branch]
338
+ return max(applicable, key=lambda line: line.version, default=None)
339
+
340
+ @property
341
+ def latest_compaction(self) -> CompactionLine | None:
342
+ """Return the highest-version checkpoint in the file, on any branch."""
343
+ return max(self.compactions, key=lambda line: line.version, default=None)
344
+
345
+ def active(self) -> tuple[CompactionLine | None, tuple[MessageLine, ...]]:
346
+ """Return the checkpoint and the messages that make up the model context.
347
+
348
+ The messages are the part of the active branch after the checkpoint's
349
+ boundary, oldest first.
350
+ """
351
+ branch = self.branch()
352
+ checkpoint = self.checkpoint_for(branch)
353
+ if checkpoint is None:
354
+ return None, branch
355
+ for position, line in enumerate(branch):
356
+ if line.id == checkpoint.parent:
357
+ return checkpoint, branch[position + 1 :]
358
+ return checkpoint, branch # unreachable: the boundary is on the branch
359
+
360
+ @property
361
+ def created_at(self) -> datetime:
362
+ """Return when the session's first record was written."""
363
+ assert self.header is not None # a file always starts with its header
364
+ return self.header.created_at
365
+
366
+ @property
367
+ def updated_at(self) -> datetime:
368
+ """Return the time of the newest record, falling back to the header."""
369
+ stamps: list[MessageLine | CompactionLine] = [*self.messages, *self.compactions]
370
+ if stamps:
371
+ return max(line.created_at for line in stamps)
372
+ assert self.header is not None # a file always starts with its header
373
+ return self.header.created_at