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
|
@@ -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
|