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,182 @@
|
|
|
1
|
+
"""The lock on a session: only the process that holds it may open the session to write.
|
|
2
|
+
|
|
3
|
+
A session's file is an append-only tree that "the caller is the single process owning the session" (see
|
|
4
|
+
:mod:`.store`) — an assumption nothing enforced. Two terminals resumed on one session each appended to it, each
|
|
5
|
+
holding a different idea of where the conversation had got to, and the tree forked behind both of them.
|
|
6
|
+
|
|
7
|
+
So a session is **locked** by the process that has it open. The lock is an operating-system file lock on a file
|
|
8
|
+
in the session's own folder (:data:`LOCK_FILE`), taken when a session becomes the active one and given up when
|
|
9
|
+
another does, and it goes with the process however the process ends: a crashed or killed ZettCode leaves no lock
|
|
10
|
+
behind to clear by hand, which a pid in a file could not promise. A process that cannot take it still shows the
|
|
11
|
+
session — reading is harmless — and does nothing that would write to it.
|
|
12
|
+
|
|
13
|
+
One process holds at most one session lock: the active one. Starting a new session, resuming or forking moves it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
from zettcode.filelock import SingletonLock, is_held
|
|
23
|
+
|
|
24
|
+
from .records import SAFE_SESSION_ID, STORE_FILE
|
|
25
|
+
|
|
26
|
+
#: The file in a session's folder the lock is taken on.
|
|
27
|
+
LOCK_FILE = "session.lock"
|
|
28
|
+
|
|
29
|
+
#: What a process that cannot write to a session says, for the one reader who asks why.
|
|
30
|
+
LOCKED_MESSAGE = "This session is open in another ZettCode process, so it is read-only here."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SessionLocked(ValueError):
|
|
34
|
+
"""An attempt to write to a session that another process has open.
|
|
35
|
+
|
|
36
|
+
A ``ValueError`` because the shell already turns one into a line in the conversation, which is what a command
|
|
37
|
+
refused for this reason should be.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str = LOCKED_MESSAGE) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionGuard(Protocol):
|
|
45
|
+
"""What the runtime asks of a session lock: take it for a session, ask whether it is held, give it up."""
|
|
46
|
+
|
|
47
|
+
def claim(self, session_id: str) -> bool:
|
|
48
|
+
"""Take the lock for ``session_id``, letting go of any other, and say whether it is held now."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def holds(self, session_id: str) -> bool:
|
|
52
|
+
"""Return whether this process holds the lock for ``session_id``."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def release(self) -> None:
|
|
56
|
+
"""Let go of the lock this process holds, if it holds one."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NoLocks:
|
|
61
|
+
"""The guard of a runtime with no store on disk: every claim succeeds, because nothing is shared."""
|
|
62
|
+
|
|
63
|
+
def claim(self, session_id: str) -> bool:
|
|
64
|
+
"""Say that the session is held; there is nobody to hold it against."""
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
def holds(self, session_id: str) -> bool:
|
|
68
|
+
"""Say that the session is held."""
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
def release(self) -> None:
|
|
72
|
+
"""Hold nothing, so there is nothing to let go of."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SessionLocks:
|
|
76
|
+
"""The lock of the one session a process has open.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
directory_of: Returns the folder of a session (the store's own ``session_dir``), which refuses an id
|
|
80
|
+
that would leave the store.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, directory_of: Callable[[str], Path]) -> None:
|
|
84
|
+
self._directory_of = directory_of
|
|
85
|
+
self._lock: SingletonLock | None = None
|
|
86
|
+
self._session_id: str | None = None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def held(self) -> str | None:
|
|
90
|
+
"""The id of the session this process holds, or ``None``."""
|
|
91
|
+
return self._session_id if self._lock is not None and self._lock.held else None
|
|
92
|
+
|
|
93
|
+
def holds(self, session_id: str) -> bool:
|
|
94
|
+
"""Return whether this process holds the lock for ``session_id`` now."""
|
|
95
|
+
return self.held == session_id
|
|
96
|
+
|
|
97
|
+
def claim(self, session_id: str) -> bool:
|
|
98
|
+
"""Take the lock for a session and let go of the one held before, whichever way it goes.
|
|
99
|
+
|
|
100
|
+
Asking again for the session already held is a yes and changes nothing. The session being left is
|
|
101
|
+
released even when the new one cannot be had: the process is no longer working in it.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: ``session_id`` is not one the store could have written.
|
|
105
|
+
"""
|
|
106
|
+
if self.holds(session_id):
|
|
107
|
+
return True
|
|
108
|
+
lock = SingletonLock(self._directory_of(session_id) / LOCK_FILE)
|
|
109
|
+
taken = lock.acquire()
|
|
110
|
+
self.release()
|
|
111
|
+
if taken:
|
|
112
|
+
self._lock, self._session_id = lock, session_id
|
|
113
|
+
return taken
|
|
114
|
+
|
|
115
|
+
def release(self) -> None:
|
|
116
|
+
"""Let go of the lock; a session that was never written leaves no folder behind."""
|
|
117
|
+
lock, session_id = self._lock, self._session_id
|
|
118
|
+
self._lock = self._session_id = None
|
|
119
|
+
if lock is None or session_id is None:
|
|
120
|
+
return
|
|
121
|
+
path = lock.path
|
|
122
|
+
lock.release()
|
|
123
|
+
# A new session that was left before its first message has a folder with nothing in it but this file;
|
|
124
|
+
# tidying it keeps every launch that was closed unused from leaving one. A session that has data keeps
|
|
125
|
+
# its lock file: the file is how the next process asks, and it is empty.
|
|
126
|
+
if not (path.parent / STORE_FILE).exists():
|
|
127
|
+
_remove_if_empty(path.parent)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _remove_if_empty(folder: Path) -> bool:
|
|
131
|
+
"""Remove a session folder that holds nothing but its lock file, and then its workspace folder if that is bare.
|
|
132
|
+
|
|
133
|
+
The workspace folder goes only when nothing is left in it, which is what an unused launch in a project
|
|
134
|
+
nobody has stored anything for leaves behind. ``rmdir`` refuses a folder with anything in it, so another
|
|
135
|
+
session, or a process that has just made its own, is never in the way of this.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Whether the session folder is gone.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
(folder / LOCK_FILE).unlink(missing_ok=True)
|
|
142
|
+
folder.rmdir()
|
|
143
|
+
except OSError:
|
|
144
|
+
return False
|
|
145
|
+
try:
|
|
146
|
+
folder.parent.rmdir()
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def prune_empty(workspace_dir: Path) -> int:
|
|
153
|
+
"""Remove the session folders in a workspace that never held a conversation and that nobody has open.
|
|
154
|
+
|
|
155
|
+
A launch that is closed in the ordinary way tidies its own folder (:meth:`SessionLocks.release`). One
|
|
156
|
+
that is killed, or whose terminal is closed under it, cannot, and leaves a folder with a lock file in
|
|
157
|
+
it and nothing else. Those are what this removes, so a store does not fill up with them. A folder is
|
|
158
|
+
only touched when it has no ``data.jsonl``, holds nothing but the lock file, and the lock is not held:
|
|
159
|
+
a session another process has just begun has its lock, and is left alone.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
workspace_dir: The workspace's folder under the store root; a folder that is not there is not an error.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
How many session folders were removed.
|
|
166
|
+
"""
|
|
167
|
+
removed = 0
|
|
168
|
+
try:
|
|
169
|
+
candidates = sorted(workspace_dir.iterdir())
|
|
170
|
+
except OSError:
|
|
171
|
+
return 0
|
|
172
|
+
for folder in candidates:
|
|
173
|
+
if not SAFE_SESSION_ID.match(folder.name) or ".." in folder.name:
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
names = {entry.name for entry in folder.iterdir()}
|
|
177
|
+
except OSError:
|
|
178
|
+
continue # not a folder, or gone since the listing
|
|
179
|
+
if not names <= {LOCK_FILE} or is_held(folder / LOCK_FILE):
|
|
180
|
+
continue
|
|
181
|
+
removed += _remove_if_empty(folder)
|
|
182
|
+
return removed
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Session metadata: one small file per session, and the old per-workspace log it replaced.
|
|
2
|
+
|
|
3
|
+
**The file.** Each session folder holds a ``metadata.json`` beside its ``data.jsonl``::
|
|
4
|
+
|
|
5
|
+
{"version": 1, "session_id": "01a1...", "created_at": "...", "updated_at": "...",
|
|
6
|
+
"title": "Fix the parser", "model": "gpt-5-mini"}
|
|
7
|
+
|
|
8
|
+
It is written when a session begins, when it is renamed, and when the model it uses changes, and
|
|
9
|
+
at no other time — about two hundred bytes, whatever the conversation does. A file written before
|
|
10
|
+
the model was recorded simply has none, and a reader that does not know the field ignores it, so
|
|
11
|
+
the layout version is unchanged. What a list needs besides that is when the
|
|
12
|
+
conversation last changed, and the conversation file already says so: its modification time moves
|
|
13
|
+
with every message it is given, so the age a list shows is read from there instead of being
|
|
14
|
+
written down again on every append. The folder is also the unit a person copies, moves or
|
|
15
|
+
deletes, so the metadata that describes a session now travels with it.
|
|
16
|
+
|
|
17
|
+
Writes replace the file whole (a temporary file, then a rename), so a reader sees the old file or
|
|
18
|
+
the new one and never half of either. A file that is missing, damaged, or names another session
|
|
19
|
+
is not an error: it is rebuilt from what the store still knows, which is how a session survives
|
|
20
|
+
losing it.
|
|
21
|
+
|
|
22
|
+
**The log it replaced.** ``metadata.jsonl`` sat beside the session folders and took one line per
|
|
23
|
+
write — and one write per *message*, so it grew without bound: thousands of lines for a
|
|
24
|
+
workspace, a few hundred bytes for every message ever sent, folded in full on every read::
|
|
25
|
+
|
|
26
|
+
{"created_at": "...", "kind": "session", "session_id": "01a1..."}
|
|
27
|
+
{"kind": "activity", "session_id": "01a1...", "updated_at": "..."}
|
|
28
|
+
{"kind": "title", "session_id": "01a1...", "title": "Fix the parser"}
|
|
29
|
+
|
|
30
|
+
Nothing writes it any more. It is still *read*, for a session that has no file of its own: that is
|
|
31
|
+
a session an older version made, and the store copies what the log says about it into the
|
|
32
|
+
session's folder the first time it is looked at, so the log is consulted once per old session and
|
|
33
|
+
never again. A torn final line is ignored, as in a conversation file, because a crash can cut the
|
|
34
|
+
line being written.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import contextlib
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import tempfile
|
|
43
|
+
from dataclasses import dataclass, replace
|
|
44
|
+
from datetime import datetime, timezone
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Annotated, Any, Literal, TypeAlias, cast
|
|
47
|
+
|
|
48
|
+
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
|
49
|
+
|
|
50
|
+
from zettcode._compat import StrEnum
|
|
51
|
+
|
|
52
|
+
#: The old per-workspace log, inside the workspace folder. Read, never written.
|
|
53
|
+
METADATA_FILE = "metadata.jsonl"
|
|
54
|
+
|
|
55
|
+
#: One session's own metadata, inside its folder.
|
|
56
|
+
SESSION_METADATA_FILE = "metadata.json"
|
|
57
|
+
|
|
58
|
+
#: Layout of :class:`SessionMetadata` on disk. A reader that finds a number it does not know
|
|
59
|
+
#: treats the file as missing and rebuilds it, so a newer layout is never half-understood.
|
|
60
|
+
SESSION_METADATA_VERSION = 1
|
|
61
|
+
|
|
62
|
+
MAX_TITLE = 200
|
|
63
|
+
MAX_MODEL = 200
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SessionMetadata(BaseModel):
|
|
67
|
+
"""What a session says about itself, in its own folder.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
version: Layout of this file.
|
|
71
|
+
session_id: The session it describes; a file that names another session — a folder
|
|
72
|
+
copied under a new name — is not trusted.
|
|
73
|
+
created_at: When the session's first record was written.
|
|
74
|
+
updated_at: The newest conversation activity this file knows of. Not the whole answer:
|
|
75
|
+
the conversation file's own modification time is newer after any later message, and
|
|
76
|
+
:func:`effective_updated_at` takes the later of the two.
|
|
77
|
+
title: The display title, or ``None`` while the session is unnamed.
|
|
78
|
+
model: The model the session last used, by the name the model picker shows, or ``None``
|
|
79
|
+
before any turn has run. It is what resuming the session switches back to. It is
|
|
80
|
+
written when it *changes*, not on every turn, so it costs the file no more writes
|
|
81
|
+
than a rename does.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
version: int = SESSION_METADATA_VERSION
|
|
85
|
+
session_id: str
|
|
86
|
+
created_at: datetime
|
|
87
|
+
updated_at: datetime
|
|
88
|
+
title: str | None = None
|
|
89
|
+
model: str | None = None
|
|
90
|
+
#: This session's own choice about letting the model request compaction: ``True`` or ``False``, or
|
|
91
|
+
#: ``None`` when it never chose and follows the configuration's default. Older files hold no choice.
|
|
92
|
+
self_compact: bool | None = Field(default=None, strict=True)
|
|
93
|
+
#: The permission level this session last ran at — ``ask``, ``safe``, ``full`` or ``deny`` — or ``None`` when
|
|
94
|
+
#: it never chose one and a run starts at the level it was built with. Resuming the session puts it back in
|
|
95
|
+
#: force, so a reader who chose ``full`` for a job does not answer the question again on the next start.
|
|
96
|
+
permission: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class SessionInfo:
|
|
101
|
+
"""What a session list needs about one session.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
session_id: Session identity.
|
|
105
|
+
title: Display title written by the application, or ``None`` when the
|
|
106
|
+
session is unnamed.
|
|
107
|
+
created_at: When the session's first record was written.
|
|
108
|
+
updated_at: When its conversation last changed; a title write does not move it,
|
|
109
|
+
so the age a list shows is conversation activity.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
session_id: str
|
|
113
|
+
title: str | None
|
|
114
|
+
created_at: datetime
|
|
115
|
+
updated_at: datetime
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def info_of(metadata: SessionMetadata, conversation: Path | None = None) -> SessionInfo:
|
|
119
|
+
"""Return the list row a session's metadata makes, with its real last-activity time.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
metadata: The session's own file.
|
|
123
|
+
conversation: The conversation file, whose modification time is when the session last
|
|
124
|
+
changed; ``None`` leaves the stored time as it is.
|
|
125
|
+
"""
|
|
126
|
+
return SessionInfo(
|
|
127
|
+
metadata.session_id,
|
|
128
|
+
metadata.title,
|
|
129
|
+
metadata.created_at,
|
|
130
|
+
effective_updated_at(metadata, conversation),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def effective_updated_at(metadata: SessionMetadata, conversation: Path | None) -> datetime:
|
|
135
|
+
"""Return when the session last changed: the later of the stored time and the file's.
|
|
136
|
+
|
|
137
|
+
The file's time wins for every session written since this layout began, because every
|
|
138
|
+
message moves it. The stored time wins when a copy of the folder has an older one — and is
|
|
139
|
+
what carries the right order across for a session an older version recorded.
|
|
140
|
+
"""
|
|
141
|
+
if conversation is None:
|
|
142
|
+
return metadata.updated_at
|
|
143
|
+
try:
|
|
144
|
+
modified = datetime.fromtimestamp(conversation.stat().st_mtime_ns / 1e9, tz=timezone.utc)
|
|
145
|
+
except OSError:
|
|
146
|
+
return metadata.updated_at
|
|
147
|
+
return max(metadata.updated_at, modified)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_session_metadata(path: Path, session_id: str) -> SessionMetadata | None:
|
|
151
|
+
"""Return a session's own metadata, or ``None`` when it has to be rebuilt.
|
|
152
|
+
|
|
153
|
+
``None`` is the answer for a missing file, one that does not parse, one in a layout this
|
|
154
|
+
version does not know, and one that names another session. None of those is an error the
|
|
155
|
+
reader can do anything about, and each has the same remedy.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
path: The session's ``metadata.json``.
|
|
159
|
+
session_id: The session the folder belongs to.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
text = path.read_bytes()
|
|
163
|
+
except OSError:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
found = SessionMetadata.model_validate_json(text)
|
|
167
|
+
except ValidationError:
|
|
168
|
+
return None
|
|
169
|
+
if found.version != SESSION_METADATA_VERSION or found.session_id != session_id:
|
|
170
|
+
return None
|
|
171
|
+
return found
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_session_metadata(path: Path, metadata: SessionMetadata) -> bool:
|
|
175
|
+
"""Replace a session's metadata file whole, and report whether it was written.
|
|
176
|
+
|
|
177
|
+
The new text goes to a temporary file in the same folder and is renamed over the old one,
|
|
178
|
+
so a reader never sees half of a write and two writers cannot interleave their bytes. A
|
|
179
|
+
failure — a read-only store, a full disk — leaves the old file as it was and returns
|
|
180
|
+
``False``: metadata is never worth failing a conversation for, and the next look at the
|
|
181
|
+
session rebuilds what was not saved.
|
|
182
|
+
"""
|
|
183
|
+
text = json.dumps(metadata.model_dump(mode="json"), ensure_ascii=False, allow_nan=False, sort_keys=True)
|
|
184
|
+
temporary: str | None = None
|
|
185
|
+
try:
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
handle = tempfile.NamedTemporaryFile(
|
|
188
|
+
"w", encoding="utf-8", dir=path.parent, prefix=f"{path.name}.", suffix=".tmp", delete=False
|
|
189
|
+
)
|
|
190
|
+
temporary = handle.name
|
|
191
|
+
with handle:
|
|
192
|
+
handle.write(text + "\n")
|
|
193
|
+
os.replace(temporary, path)
|
|
194
|
+
except OSError:
|
|
195
|
+
if temporary is not None:
|
|
196
|
+
with contextlib.suppress(OSError):
|
|
197
|
+
os.unlink(temporary)
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# -- the log this replaced: read, to carry old sessions over ---------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class MetadataKind(StrEnum):
|
|
206
|
+
"""Discriminator values of the old log's records, written as ``kind``."""
|
|
207
|
+
|
|
208
|
+
SESSION = "session"
|
|
209
|
+
ACTIVITY = "activity"
|
|
210
|
+
TITLE = "title"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SessionStarted(BaseModel):
|
|
214
|
+
"""A session's first record in the old log: when its conversation began."""
|
|
215
|
+
|
|
216
|
+
kind: Literal[MetadataKind.SESSION] = MetadataKind.SESSION
|
|
217
|
+
session_id: str
|
|
218
|
+
created_at: datetime
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class SessionActivity(BaseModel):
|
|
222
|
+
"""One activity stamp in the old log: the newest one is the session's last change."""
|
|
223
|
+
|
|
224
|
+
kind: Literal[MetadataKind.ACTIVITY] = MetadataKind.ACTIVITY
|
|
225
|
+
session_id: str
|
|
226
|
+
updated_at: datetime
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class SessionTitle(BaseModel):
|
|
230
|
+
"""One display title in the old log; the newest line for a session replaces the earlier ones."""
|
|
231
|
+
|
|
232
|
+
kind: Literal[MetadataKind.TITLE] = MetadataKind.TITLE
|
|
233
|
+
session_id: str
|
|
234
|
+
title: str
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
MetadataRecord: TypeAlias = Annotated[SessionStarted | SessionActivity | SessionTitle, Field(discriminator="kind")]
|
|
238
|
+
# The alias is a ``TypeForm`` pydantic accepts; mypy 1.20 does not read an ``Annotated`` union as one.
|
|
239
|
+
METADATA: TypeAdapter[SessionStarted | SessionActivity | SessionTitle] = TypeAdapter(cast(Any, MetadataRecord))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def read_metadata(path: Path) -> list[SessionInfo]:
|
|
243
|
+
"""Fold the old log into one row per session, newest activity first.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
path: The log; a missing file is an empty log, not an error.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Every session the log describes. Activity and title lines whose session
|
|
250
|
+
line is absent are ignored, so a truncated or edited file can never
|
|
251
|
+
invent a session.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ValueError: A line other than the last is damaged.
|
|
255
|
+
"""
|
|
256
|
+
rows: dict[str, SessionInfo] = {}
|
|
257
|
+
if not path.is_file():
|
|
258
|
+
return []
|
|
259
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
260
|
+
for index, line in enumerate(lines):
|
|
261
|
+
if not line.strip():
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
record = METADATA.validate_json(line)
|
|
265
|
+
except ValidationError as error:
|
|
266
|
+
if index == len(lines) - 1:
|
|
267
|
+
break # A crash can tear the line being appended; the rest is intact.
|
|
268
|
+
raise ValueError(f"Corrupt metadata file {path.name} at line {index + 1}") from error
|
|
269
|
+
match record:
|
|
270
|
+
case SessionStarted():
|
|
271
|
+
rows[record.session_id] = SessionInfo(record.session_id, None, record.created_at, record.created_at)
|
|
272
|
+
case SessionActivity() if (row := rows.get(record.session_id)) is not None:
|
|
273
|
+
rows[record.session_id] = replace(row, updated_at=max(row.updated_at, record.updated_at))
|
|
274
|
+
case SessionTitle() if (row := rows.get(record.session_id)) is not None:
|
|
275
|
+
rows[record.session_id] = replace(row, title=record.title)
|
|
276
|
+
sessions = list(rows.values())
|
|
277
|
+
sessions.sort(key=lambda info: (info.updated_at, info.session_id), reverse=True)
|
|
278
|
+
return sessions
|