zettcode-gateway 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 (40) hide show
  1. zettcode/gateway/__init__.py +22 -0
  2. zettcode/gateway/app.py +454 -0
  3. zettcode/gateway/backend.py +368 -0
  4. zettcode/gateway/chat.py +142 -0
  5. zettcode/gateway/cli.py +495 -0
  6. zettcode/gateway/py.typed +0 -0
  7. zettcode/gateway/runfile.py +145 -0
  8. zettcode/gateway/service.py +1219 -0
  9. zettcode/gateway/settings.py +62 -0
  10. zettcode/gateway/static/app.css +1434 -0
  11. zettcode/gateway/static/app.js +3 -0
  12. zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  13. zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  14. zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  15. zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  16. zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  17. zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  18. zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  19. zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  20. zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  21. zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  22. zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  23. zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  24. zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  25. zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  26. zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  27. zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  28. zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  29. zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  30. zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  31. zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  32. zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +1 -0
  33. zettcode/gateway/static/chunks/render-G6G3TSB3.js +341 -0
  34. zettcode/gateway/static/icon.png +0 -0
  35. zettcode/gateway/static/index.html +23 -0
  36. zettcode/gateway/static/logo.svg +205 -0
  37. zettcode/gateway/static/manifest.webmanifest +11 -0
  38. zettcode_gateway-0.3.4.dist-info/METADATA +21 -0
  39. zettcode_gateway-0.3.4.dist-info/RECORD +40 -0
  40. zettcode_gateway-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,368 @@
1
+ """What the gateway needs from the agent, as an interface, and the implementation that gets it from the SDK.
2
+
3
+ The gateway's logic (:mod:`.service`) is written against :class:`Backend`, so it can be tested with a backend that
4
+ answers from memory. :class:`SdkBackend` is the real one and the only module of the gateway that touches
5
+ ``zettcode.sdk.Client``: it is a session of its own, registered with the broker like a terminal's, which has a person
6
+ at it and runs no model.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from abc import ABC, abstractmethod
14
+ from collections.abc import Callable, Sequence
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from zettcode.modes import MODE_FOLLOWUP
19
+ from zettcode.sdk import daemons as sdk_daemons
20
+ from zettcode.sdk.client import Client
21
+ from zettcode.sdk.errors import ConfigurationError, SendError, SessionLockedError, SessionNotFoundError, ZettCodeError
22
+ from zettcode.sdk.models import add_model, list_models, remove_model, update_model
23
+ from zettcode.sdk.types import (
24
+ DaemonInfo,
25
+ Delivered,
26
+ Image,
27
+ IncomingMessage,
28
+ Message,
29
+ ModelInfo,
30
+ Peer,
31
+ Receipt,
32
+ SendLimits,
33
+ StreamEvent,
34
+ )
35
+
36
+ #: What the gateway's own session is called, so the sessions it writes to can tell who is speaking.
37
+ TITLE = "Web gateway"
38
+
39
+ #: The file in the gateway's folder that remembers the id of its session, so a restart is the same session to the
40
+ #: daemons that were told where to answer.
41
+ HOST_FILE = "host.json"
42
+
43
+
44
+ class GatewayStartError(ZettCodeError):
45
+ """The gateway could not become a session: no model is configured, or another gateway is running."""
46
+
47
+
48
+ class Backend(ABC):
49
+ """The agent as the gateway sees it: who is there, what they said, and how to speak to them."""
50
+
51
+ @property
52
+ @abstractmethod
53
+ def gateway_id(self) -> str:
54
+ """Return the id the gateway is registered under: what a daemon replies to."""
55
+
56
+ @property
57
+ @abstractmethod
58
+ def handle(self) -> str:
59
+ """Return the name the others call the gateway by, or an empty string before it is registered."""
60
+
61
+ @property
62
+ @abstractmethod
63
+ def reachable(self) -> bool:
64
+ """Return whether the broker is connected: without it nobody can be written to."""
65
+
66
+ @property
67
+ @abstractmethod
68
+ def limits(self) -> SendLimits:
69
+ """Return what the broker accepts from a sender."""
70
+
71
+ @abstractmethod
72
+ async def start(self, on_message: Callable[[IncomingMessage], Receipt], on_change: Callable[[], None]) -> None:
73
+ """Become reachable. ``on_message`` is told of each message another session sends; ``on_change`` of any
74
+ change in who is there or whether the broker is."""
75
+
76
+ @abstractmethod
77
+ async def close(self) -> None:
78
+ """Leave the broker and release the session. Safe to call twice."""
79
+
80
+ @abstractmethod
81
+ def peers(self) -> tuple[Peer, ...]:
82
+ """Return the other registered sessions, from memory."""
83
+
84
+ def watch(self, session_id: str) -> None: # noqa: B027 - a backend with no streams watches nothing
85
+ """Ask to be told what a session's turns say as they happen. Asking twice is the same as once."""
86
+
87
+ def unwatch(self, session_id: str) -> None: # noqa: B027
88
+ """Stop being told what a session's turns say."""
89
+
90
+ def on_stream(self, callback: Callable[[StreamEvent], None]) -> None: # noqa: B027
91
+ """Call ``callback`` on the event loop with every event of a watched session's turn."""
92
+
93
+ def observe(self, callback: Callable[[dict[str, Any]], None] | None) -> None: # noqa: B027
94
+ """Call ``callback`` with every message that passes through the broker, and when who is here changes."""
95
+
96
+ async def graph(self) -> dict[str, Any]:
97
+ """Return the broker's picture of who talks to whom (see ``Client.traffic_graph``); empty without a broker."""
98
+ return {"sessions": [], "groups": [], "edges": [], "recent": []}
99
+
100
+ async def older(self, before: int, limit: int, group: str = "") -> dict[str, Any]:
101
+ """Return the stored messages before id ``before`` (see ``Client.traffic_older``); a group's own with ``group``."""
102
+ return {"messages": [], "more": False}
103
+
104
+ async def send_group(self, group: str, text: str) -> list[Delivered]:
105
+ """Write to a group chat as the person at the gateway and return who took it (see ``Client.send_group``)."""
106
+ raise SendError("offline", "not connected to the session broker")
107
+
108
+ async def join_group(self, group: str) -> None:
109
+ """Make the gateway a member of a group so that it can write to it (see ``Client.join_group``)."""
110
+ raise SendError("offline", "not connected to the session broker")
111
+
112
+ async def timeline(self, since: float, until: float, limit: int) -> dict[str, Any]:
113
+ """Return the stored messages of a stretch of the past (see ``Client.traffic_timeline``)."""
114
+ return {"messages": [], "truncated": False, "span": None, "now": until}
115
+
116
+ @abstractmethod
117
+ async def send(self, to: str, text: str, *, direct: bool = False, images: Sequence[Image] = ()) -> Delivered:
118
+ """Send a message as the person at the gateway and return once the session has taken it.
119
+
120
+ A backend sends it as the person at the gateway and never steers a turn the page does not show: a message
121
+ waits for the session's current request to end (see the implementation for the mode it uses).
122
+
123
+ With ``direct``, a session that can (``Peer.direct``) runs it as its reader's own prompt and sends its final
124
+ answer back here; for any other the flag is ignored. Only a ``direct`` message may carry ``images``.
125
+
126
+ Raises:
127
+ zettcode.sdk.errors.SendError: Nobody was reached, and why.
128
+ """
129
+
130
+ def locked(self, session_id: str, workspace: str) -> bool: # noqa: ARG002
131
+ """Return whether another process has this stored session open (its lock is held)."""
132
+ return False
133
+
134
+ @abstractmethod
135
+ async def history(self, session_id: str, workspace: str) -> list[Message]:
136
+ """Return a stored conversation of any session, or raise ``SessionNotFoundError``."""
137
+
138
+ @abstractmethod
139
+ def stamp(self, session_id: str, workspace: str) -> tuple[int, int] | None:
140
+ """Return a value that changes when the stored conversation does, or ``None`` when there is none."""
141
+
142
+ @abstractmethod
143
+ def models(self) -> list[ModelInfo]:
144
+ """Return the models a daemon can run on, the first being the default."""
145
+
146
+ @abstractmethod
147
+ def add_model(self, entry: dict[str, object]) -> list[ModelInfo]:
148
+ """Write a model into the configuration file and return every model it now lists (``ValueError`` if unusable)."""
149
+
150
+ @abstractmethod
151
+ def update_model(self, name: str, changes: dict[str, object]) -> list[ModelInfo]:
152
+ """Change one model; a key whose value is ``None`` is removed, and the token is kept unless given."""
153
+
154
+ @abstractmethod
155
+ def remove_model(self, name: str) -> list[ModelInfo]:
156
+ """Remove one model; the last cannot go (``ValueError``)."""
157
+
158
+ @abstractmethod
159
+ def daemons(self) -> list[DaemonInfo]:
160
+ """Return every daemon on this machine, newest first."""
161
+
162
+ @abstractmethod
163
+ async def spawn(
164
+ self,
165
+ workspace: Path,
166
+ *,
167
+ model: str | None,
168
+ effort: str | None,
169
+ resume: str | None,
170
+ name: str | None,
171
+ permission: str,
172
+ ) -> DaemonInfo:
173
+ """Start a daemon and return it once it can be messaged."""
174
+
175
+ @abstractmethod
176
+ async def stop(self, session_id: str, *, force: bool) -> str:
177
+ """Stop a daemon and keep its record, listed as offline, and say what happened."""
178
+
179
+ @abstractmethod
180
+ async def delete(self, session_id: str) -> str:
181
+ """Remove a stopped daemon for good, record and conversation, and say what happened."""
182
+
183
+ @abstractmethod
184
+ def log(self, session_id: str, lines: int) -> str:
185
+ """Return the end of a daemon's own log."""
186
+
187
+
188
+ class SdkBackend(Backend):
189
+ """The backend that is a real session: an SDK client that joined the broker, and the SDK's daemon calls."""
190
+
191
+ def __init__(self, workspace: Path, directory: Path) -> None:
192
+ """Remember where the gateway's session works and where it keeps its id; nothing starts until :meth:`start`."""
193
+ self.workspace = workspace
194
+ self.directory = directory
195
+ self._client: Client | None = None
196
+
197
+ # -- the session ------------------------------------------------------------------------------
198
+ async def start(self, on_message: Callable[[IncomingMessage], Receipt], on_change: Callable[[], None]) -> None:
199
+ """Make the client, take its old id back when it has one, join the broker and watch who is there.
200
+
201
+ Raises:
202
+ GatewayStartError: No model is configured (the session needs one to exist, though it never calls it), or
203
+ another gateway holds the session.
204
+ """
205
+ self.workspace.mkdir(parents=True, exist_ok=True)
206
+ client = self._build(self._remembered())
207
+ try:
208
+ if client.session.locked:
209
+ raise GatewayStartError("another gateway is already running for this user")
210
+ if client.session.title is None:
211
+ await client.session.rename(TITLE)
212
+ self._remember(client.session.id)
213
+ if not await client.join_sessions(on_message):
214
+ raise GatewayStartError("sessions are switched off in the configuration, so nothing can be reached")
215
+ except BaseException:
216
+ await client.aclose()
217
+ raise
218
+ client.watch_peers(on_change)
219
+ self._client = client
220
+
221
+ def _build(self, resume: str | None) -> Client:
222
+ """Return the client; a session that was remembered and has since gone is replaced by a new one."""
223
+ settings = {
224
+ "sessions_enabled": True,
225
+ "update_enabled": False,
226
+ "plugins_enabled": False,
227
+ "mcp_enabled": False,
228
+ "skills_enabled": False,
229
+ "agents_md_enabled": False,
230
+ "ask_user_enabled": False,
231
+ "subagent_enabled": False,
232
+ "permission": "deny",
233
+ }
234
+ try:
235
+ return Client(self.workspace, resume=resume, **settings) # type: ignore[arg-type]
236
+ except SessionNotFoundError:
237
+ return Client(self.workspace, **settings) # type: ignore[arg-type]
238
+ except ConfigurationError as error:
239
+ raise GatewayStartError(f"{error}. Add a model with `zettcode` (/config) or in the config file.") from error
240
+ except SessionLockedError as error:
241
+ raise GatewayStartError("another gateway is already running for this user") from error
242
+
243
+ def _remembered(self) -> str | None:
244
+ try:
245
+ data = json.loads((self.directory / HOST_FILE).read_text(encoding="utf-8"))
246
+ except (OSError, ValueError):
247
+ return None
248
+ session = data.get("session") if isinstance(data, dict) else None
249
+ return session if isinstance(session, str) and session else None
250
+
251
+ def _remember(self, session_id: str) -> None:
252
+ self.directory.mkdir(parents=True, exist_ok=True)
253
+ temporary = self.directory / f".{HOST_FILE}.{os.getpid()}.tmp"
254
+ temporary.write_text(json.dumps({"session": session_id}), encoding="utf-8")
255
+ os.replace(temporary, self.directory / HOST_FILE)
256
+
257
+ @property
258
+ def client(self) -> Client:
259
+ """Return the client; asking before :meth:`start` is a bug of the caller's."""
260
+ if self._client is None:
261
+ raise GatewayStartError("the gateway has not started")
262
+ return self._client
263
+
264
+ async def close(self) -> None:
265
+ client, self._client = self._client, None
266
+ if client is not None:
267
+ client.watch_peers(None)
268
+ await client.aclose()
269
+
270
+ # -- who is there and what was said -----------------------------------------------------------
271
+ @property
272
+ def gateway_id(self) -> str:
273
+ return self.client.session.id
274
+
275
+ @property
276
+ def handle(self) -> str:
277
+ return self.client.handle
278
+
279
+ @property
280
+ def reachable(self) -> bool:
281
+ return self._client is not None and self._client.reachable
282
+
283
+ @property
284
+ def limits(self) -> SendLimits:
285
+ return self.client.send_limits
286
+
287
+ def peers(self) -> tuple[Peer, ...]:
288
+ return self.client.peers()
289
+
290
+ def watch(self, session_id: str) -> None:
291
+ self.client.watch(session_id)
292
+
293
+ def unwatch(self, session_id: str) -> None:
294
+ self.client.unwatch(session_id)
295
+
296
+ def on_stream(self, callback: Callable[[StreamEvent], None]) -> None:
297
+ self.client.watch_streams(callback)
298
+
299
+ def observe(self, callback: Callable[[dict[str, Any]], None] | None) -> None:
300
+ self.client.watch_traffic(callback)
301
+
302
+ async def graph(self) -> dict[str, Any]:
303
+ return await self.client.traffic_graph()
304
+
305
+ async def older(self, before: int, limit: int, group: str = "") -> dict[str, Any]:
306
+ return await self.client.traffic_older(before, limit, group)
307
+
308
+ async def send_group(self, group: str, text: str) -> list[Delivered]:
309
+ return await self.client.send_group(group, text, mode=MODE_FOLLOWUP)
310
+
311
+ async def join_group(self, group: str) -> None:
312
+ await self.client.join_group(group)
313
+
314
+ async def timeline(self, since: float, until: float, limit: int) -> dict[str, Any]:
315
+ return await self.client.traffic_timeline(since, until, limit)
316
+
317
+ async def send(self, to: str, text: str, *, direct: bool = False, images: Sequence[Image] = ()) -> Delivered:
318
+ # A page keeps a conversation instead of watching the session's live turn, so its message never steers one:
319
+ # a person typing here cannot see what they would be interrupting.
320
+ return await self.client.send(to, text, direct=direct, mode=MODE_FOLLOWUP, images=images)
321
+
322
+ def locked(self, session_id: str, workspace: str) -> bool:
323
+ return self.client.sessions.open_elsewhere(session_id, workspace=workspace or None)
324
+
325
+ async def history(self, session_id: str, workspace: str) -> list[Message]:
326
+ return await self.client.sessions.history_of(session_id, workspace=workspace or None, full=True)
327
+
328
+ def stamp(self, session_id: str, workspace: str) -> tuple[int, int] | None:
329
+ return self.client.sessions.stamp(session_id, workspace=workspace or None)
330
+
331
+ # -- daemons ----------------------------------------------------------------------------------
332
+ def models(self) -> list[ModelInfo]:
333
+ return list(list_models())
334
+
335
+ def add_model(self, entry: dict[str, object]) -> list[ModelInfo]:
336
+ return list(add_model(entry))
337
+
338
+ def update_model(self, name: str, changes: dict[str, object]) -> list[ModelInfo]:
339
+ return list(update_model(name, changes))
340
+
341
+ def remove_model(self, name: str) -> list[ModelInfo]:
342
+ return list(remove_model(name))
343
+
344
+ def daemons(self) -> list[DaemonInfo]:
345
+ return sdk_daemons.list_daemons()
346
+
347
+ async def spawn(
348
+ self,
349
+ workspace: Path,
350
+ *,
351
+ model: str | None,
352
+ effort: str | None,
353
+ resume: str | None,
354
+ name: str | None,
355
+ permission: str,
356
+ ) -> DaemonInfo:
357
+ return await sdk_daemons.spawn_daemon(
358
+ workspace, model=model, effort=effort, resume=resume, name=name, permission=permission
359
+ )
360
+
361
+ async def stop(self, session_id: str, *, force: bool) -> str:
362
+ return await sdk_daemons.stop_daemon(session_id, force=force, keep=True)
363
+
364
+ async def delete(self, session_id: str) -> str:
365
+ return await sdk_daemons.delete_daemon(session_id)
366
+
367
+ def log(self, session_id: str, lines: int) -> str:
368
+ return sdk_daemons.daemon_log(session_id, lines=lines)
@@ -0,0 +1,142 @@
1
+ """A stored conversation as a chat: the SDK's messages turned into the items a chat view draws.
2
+
3
+ The gateway shows a session the way a messenger shows a contact, so a conversation is not a transcript of model
4
+ turns but a list of things said (by this gateway, by the session) and a few quiet marks for what the agent did in
5
+ between. This module is that translation and nothing else: no I/O, and nothing about HTTP, so every rule here is a
6
+ function of the messages it is given.
7
+
8
+ An item is a plain dict with a ``kind``:
9
+
10
+ * ``out`` -- this gateway said it (a message the stored conversation records as relayed from the gateway's id);
11
+ * ``from`` -- another session or program said it to this one (``sender`` and ``via`` say who);
12
+ * ``user`` -- what the person at that session typed, when it is a session with a person at it;
13
+ * ``in`` -- what the session's agent answered in words;
14
+ * ``reply`` -- the agent used ``send_message`` to speak to this gateway, which is the way a daemon answers;
15
+ * ``tool`` -- a tool the agent ran, with a short account of its arguments and how it ended;
16
+ * ``note`` -- the summary a compaction left in place of older messages.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import re
23
+ from collections.abc import Iterable, Sequence
24
+
25
+ from zettcode.sdk.types import Message, brief_of
26
+
27
+ #: The line added after what the reader typed, asking the agent to answer with ``send_message``. A daemon answers a
28
+ #: message only when its model chooses to, and a daemon that goes quiet cannot be told from one with nothing to say.
29
+ REPLY_REQUEST = (
30
+ '[Web gateway: when you have finished, reply with send_message(to="{id}", message=...). '
31
+ "If there is nothing to report, say so in one line.]"
32
+ )
33
+
34
+ #: Matches the request above whatever id it carries, so the chat shows what the reader typed and not the scaffolding.
35
+ _BEFORE, _AFTER = (re.escape(part) for part in REPLY_REQUEST.split("{id}"))
36
+ _REPLY_REQUEST = re.compile(rf"\n\n{_BEFORE}[^\"]*{_AFTER}\Z")
37
+
38
+ #: Longest tool output kept on an item; the chat shows it folded and has no use for more.
39
+ OUTPUT_LIMIT = 1_200
40
+
41
+ #: The tool an agent speaks to another session with.
42
+ SEND_TOOL = "send_message"
43
+
44
+ #: The kinds that are words said to the reader of the chat, as opposed to what the agent did.
45
+ SPOKEN = frozenset({"in", "reply"})
46
+
47
+
48
+ def with_reply_request(text: str, gateway_id: str) -> str:
49
+ """Return the text to send: what the reader typed, then the request to answer with ``send_message``."""
50
+ return f"{text}\n\n{REPLY_REQUEST.format(id=gateway_id)}"
51
+
52
+
53
+ def strip_reply_request(text: str) -> str:
54
+ """Return what the reader typed, without the request :func:`with_reply_request` added."""
55
+ return _REPLY_REQUEST.sub("", text)
56
+
57
+
58
+ def items_of(messages: Sequence[Message], gateway_id: str, *, handle: str = "") -> list[dict[str, object]]:
59
+ """Return the chat items for a conversation, oldest first.
60
+
61
+ Args:
62
+ messages: The conversation as the SDK reads it.
63
+ gateway_id: The id this gateway is registered under, which marks a message as its own.
64
+ handle: The name the others call this gateway by, which an agent may address a reply to instead of the id.
65
+ """
66
+ results: dict[str, tuple[bool, str]] = {
67
+ message.tool_call_id: (message.ok, message.text)
68
+ for message in messages
69
+ if message.role == "tool" and message.tool_call_id
70
+ }
71
+ ours = {value for value in (gateway_id, handle) if value}
72
+ items: list[dict[str, object]] = []
73
+ for message in messages:
74
+ if message.role == "summary":
75
+ items.append({"kind": "note", "text": message.text})
76
+ elif message.role == "user":
77
+ items.append(_user_item(message, gateway_id))
78
+ elif message.role == "assistant":
79
+ text = message.text.strip()
80
+ if text:
81
+ items.append({"kind": "in", "text": text})
82
+ for call in message.tool_calls:
83
+ items.append(_tool_item(call.id, call.name, call.arguments, results, ours))
84
+ return items
85
+
86
+
87
+ def picture_id(data: bytes) -> str:
88
+ """Return the name a picture is served under: a prefix of its hash, so the same file is the same address."""
89
+ return hashlib.sha256(data).hexdigest()[:20]
90
+
91
+
92
+ def _user_item(message: Message, gateway_id: str) -> dict[str, object]:
93
+ text = strip_reply_request(message.text)
94
+ relay = message.relay
95
+ if relay is None:
96
+ item: dict[str, object] = {"kind": "user", "text": text}
97
+ elif relay.sender_id == gateway_id:
98
+ item = {"kind": "out", "text": text}
99
+ else:
100
+ item = {"kind": "from", "text": text, "sender": relay.sender, "via": relay.via}
101
+ if message.pictures:
102
+ # Named, not inlined: the pictures are fetched one by one and cached, not sent again with every refresh.
103
+ item["images"] = [
104
+ {"id": picture_id(picture.data), "media_type": picture.media_type, "bytes": len(picture.data)}
105
+ for picture in message.pictures
106
+ ]
107
+ return item
108
+
109
+
110
+ def _tool_item(
111
+ call_id: str, name: str, arguments: dict[str, object], results: dict[str, tuple[bool, str]], ours: set[str]
112
+ ) -> dict[str, object]:
113
+ spoken_to = arguments.get("to")
114
+ said = arguments.get("message")
115
+ if name == SEND_TOOL and isinstance(spoken_to, str) and spoken_to in ours and isinstance(said, str):
116
+ return {"kind": "reply", "text": said.strip()}
117
+ item: dict[str, object] = {"kind": "tool", "name": name, "brief": brief_of(arguments)}
118
+ if call_id in results:
119
+ ok, output = results[call_id]
120
+ item["ok"] = ok
121
+ item["output"] = output if len(output) <= OUTPUT_LIMIT else output[:OUTPUT_LIMIT] + "\n..."
122
+ return item
123
+
124
+
125
+ def spoken(items: Iterable[dict[str, object]]) -> int:
126
+ """Return how many things the session said to this gateway: what a chat counts as unread."""
127
+ return sum(1 for item in items if item["kind"] in SPOKEN)
128
+
129
+
130
+ def preview(items: Sequence[dict[str, object]], limit: int = 90) -> str:
131
+ """Return the line a chat list shows under a name: the last thing said, with ``You:`` before what we said."""
132
+ for item in reversed(items):
133
+ kind = item["kind"]
134
+ if kind not in ("out", "in", "reply", "from", "user"):
135
+ continue
136
+ line = " ".join(str(item["text"]).split())
137
+ if not line:
138
+ continue
139
+ if kind == "out":
140
+ line = f"You: {line}"
141
+ return line if len(line) <= limit else line[: limit - 1] + "\u2026"
142
+ return ""