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,1219 @@
1
+ """The gateway: the threads a page lists, what is sent and said in them, and the events that keep the page current.
2
+
3
+ A *thread* is one session as the page shows it: a daemon this machine runs, another session that is registered with
4
+ the broker, or a sender that once wrote to us. Everything about a thread comes from the SDK through :class:`.backend.Backend`
5
+ and nothing is stored here but what a page would otherwise forget: which of a thread's messages have been read.
6
+
7
+ Where the words are. A session's side of the conversation is in its stored conversation (``Backend.history``), and
8
+ that is what a thread shows, because a daemon does not always send what it concludes: its last answer may only be in
9
+ its own conversation. The broker's part is the control plane -- whether a message was taken, who is there, that
10
+ something arrived -- and the message a session sends us is shown from the stored conversation too, once, and not twice.
11
+ Only a sender with no stored conversation (a program that registered as external) is shown from what reached us.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import base64
18
+ import contextlib
19
+ import itertools
20
+ import json
21
+ import logging
22
+ import os
23
+ import re
24
+ import time
25
+ import unicodedata
26
+ from collections import deque
27
+ from collections.abc import Callable, Mapping, Sequence
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from zettcode.sdk.errors import (
33
+ ConfigurationError,
34
+ DaemonStartError,
35
+ SendError,
36
+ SessionLockedError,
37
+ SessionNotFoundError,
38
+ ZettCodeError,
39
+ )
40
+ from zettcode.sdk.types import (
41
+ IMAGE_TYPES,
42
+ DaemonInfo,
43
+ Image,
44
+ IncomingMessage,
45
+ Message,
46
+ ModelInfo,
47
+ Peer,
48
+ Receipt,
49
+ SendLimits,
50
+ StreamEvent,
51
+ is_direct_notice,
52
+ )
53
+ from zettcode.version import __version__
54
+
55
+ from .backend import Backend
56
+ from .chat import items_of, preview, spoken, with_reply_request
57
+ from .chat import picture_id as picture_name
58
+ from .settings import Settings
59
+
60
+ #: Seconds between two looks at the daemons, the roster and the stored conversations when nothing woke the watcher.
61
+ POLL_SECONDS = 1.5
62
+
63
+ #: How long a probe of "does another process have this session open" is kept before it is asked again.
64
+ LOCK_PROBE_SECONDS = 5.0
65
+
66
+ #: The most of one turn's answer kept to show a page that opens in the middle of it, and how many tool calls.
67
+ LIVE_TEXT_LIMIT = 200_000
68
+ LIVE_TOOL_LIMIT = 40
69
+
70
+ #: A turn that has said nothing for this long while its session says it is idle is taken to have ended unseen.
71
+ LIVE_STALE_SECONDS = 4.0
72
+
73
+ #: How many notes about a session's failed direct turns a chat keeps.
74
+ NOTE_LIMIT = 4
75
+
76
+ #: How many chats' pictures are kept in memory for the page to fetch one by one, and the most bytes of pictures one chat
77
+ #: may have kept; a chat with more keeps only their names, and its bytes are read when they are asked for.
78
+ PICTURE_CHATS = 4
79
+ PICTURE_BYTES = 32 << 20
80
+
81
+ #: What a picture's name looks like (see :func:`~.chat.picture_id`): anything else is not one, and nothing is read for it.
82
+ PICTURE_NAME = re.compile(r"^[0-9a-f]{20}$")
83
+
84
+ #: The most messages kept from senders that have no stored conversation, per sender.
85
+ INBOX_LIMIT = 200
86
+
87
+ #: The most items a thread returns unless asked for all of them.
88
+ ITEM_LIMIT = 400
89
+
90
+ #: The longest a daemon's label may be.
91
+ NAME_LIMIT = 40
92
+
93
+ #: A session id as the store writes them.
94
+ SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
95
+
96
+ #: The HTTP status of each reason a send can fail for; anything else is the sender's mistake.
97
+ SEND_STATUS = {
98
+ "offline": 503,
99
+ "unknown": 404,
100
+ "ambiguous": 409,
101
+ "gone": 410,
102
+ "busy": 503,
103
+ "rejected": 409,
104
+ "rate": 429,
105
+ "too_long": 413,
106
+ "empty": 400,
107
+ "timeout": 504,
108
+ "lost": 504,
109
+ "loop": 422,
110
+ "invalid": 400,
111
+ }
112
+
113
+
114
+ def read_pictures(raw: object, limits: SendLimits) -> list[Image]:
115
+ """Read the pictures a page sent (``[{media_type, data}]``, the file in base64) and refuse what the broker would.
116
+
117
+ Checked here so that a page is told at once and in its own terms: how many, how large, and of a type a model takes.
118
+ That what a file says it is matches what it is, the broker decides.
119
+ """
120
+ if raw is None:
121
+ return []
122
+ if not isinstance(raw, list):
123
+ raise GatewayError(400, "invalid", "images is a list")
124
+ if len(raw) > limits.max_images:
125
+ raise GatewayError(413, "too_long", f"At most {limits.max_images} pictures go with one message")
126
+ pictures: list[Image] = []
127
+ total = 0
128
+ for item in raw:
129
+ if not isinstance(item, Mapping) or not isinstance(item.get("data"), str):
130
+ raise GatewayError(400, "invalid", "A picture is {media_type, data}")
131
+ kind = item.get("media_type")
132
+ if not isinstance(kind, str) or kind not in IMAGE_TYPES:
133
+ raise GatewayError(400, "invalid", f"A picture is one of: {', '.join(sorted(IMAGE_TYPES))}")
134
+ # Before it is decoded: a file that is too large is turned away for its length, not after it is all in memory.
135
+ if len(item["data"]) > (limits.max_image_bytes + 2) // 3 * 4:
136
+ raise GatewayError(413, "too_long", f"A picture is limited to {limits.max_image_bytes >> 20} MiB")
137
+ try:
138
+ data = base64.b64decode(item["data"], validate=True)
139
+ picture = Image(data, kind)
140
+ except ValueError as error:
141
+ raise GatewayError(400, "invalid", f"A picture could not be read: {error}") from None
142
+ if len(data) > limits.max_image_bytes:
143
+ raise GatewayError(413, "too_long", f"A picture is limited to {limits.max_image_bytes >> 20} MiB")
144
+ total += len(data)
145
+ if total > limits.max_images_bytes:
146
+ raise GatewayError(
147
+ 413,
148
+ "too_long",
149
+ f"The pictures of a message are limited to {limits.max_images_bytes >> 20} MiB together",
150
+ )
151
+ pictures.append(picture)
152
+ return pictures
153
+
154
+
155
+ logger = logging.getLogger("zettcode.gateway")
156
+
157
+
158
+ class GatewayError(Exception):
159
+ """A request the gateway refuses, as a status, a code a page can branch on and a sentence for a person."""
160
+
161
+ def __init__(self, status: int, code: str, message: str, **extra: Any) -> None:
162
+ super().__init__(message)
163
+ self.status = status
164
+ self.code = code
165
+ self.extra = extra
166
+
167
+ def to_dict(self) -> dict[str, Any]:
168
+ """Return the body the page receives."""
169
+ return {"error": {"code": self.code, "message": str(self), **self.extra}}
170
+
171
+
172
+ #: The keys of a model entry the page may write, each with the type it must have. The token is among them but is only
173
+ #: ever written: no response carries it back.
174
+ MODEL_FIELDS: dict[str, tuple[type, ...]] = {
175
+ "model": (str,),
176
+ "token": (str,),
177
+ "display_model": (str,),
178
+ "base_url": (str,),
179
+ "anthropic": (bool,),
180
+ "responses_api": (bool,),
181
+ "chat": (bool,),
182
+ "multimodal": (bool,),
183
+ "temperature": (int, float),
184
+ "web_search": (bool,),
185
+ "web_search_name": (str,),
186
+ "context_window": (int,),
187
+ "compact_percent": (int, float),
188
+ "reasoning_efforts": (list,),
189
+ }
190
+
191
+
192
+ def model_fields(data: Mapping[str, Any], *, creating: bool) -> dict[str, object]:
193
+ """Return the keys of a model entry a request names, refusing a key or a type the file does not have.
194
+
195
+ When creating, a blank string or an unset flag is left out, so the file holds only what was chosen. When changing,
196
+ ``None`` stays: it means remove the key, which is how a temperature goes back to the provider's own. A blank
197
+ token is dropped in both cases, because the page never shows the stored one and an empty box means keep it.
198
+ """
199
+ out: dict[str, object] = {}
200
+ for key, value in data.items():
201
+ kinds = MODEL_FIELDS.get(key)
202
+ if kinds is None:
203
+ raise GatewayError(400, "invalid", f"{key} is not a setting of a model")
204
+ if value is None:
205
+ if key in ("model", "token"):
206
+ continue
207
+ if not creating:
208
+ out[key] = None
209
+ continue
210
+ if isinstance(value, bool) != (bool in kinds) or not isinstance(value, kinds):
211
+ raise GatewayError(400, "invalid", f"{key} has the wrong type")
212
+ if isinstance(value, str):
213
+ value = value.strip()
214
+ if key == "token" and not value:
215
+ continue
216
+ if not value:
217
+ if creating:
218
+ continue
219
+ if key == "model":
220
+ raise GatewayError(400, "invalid", "The model id cannot be empty")
221
+ out[key] = None
222
+ continue
223
+ if creating and value is False:
224
+ continue
225
+ if key == "reasoning_efforts":
226
+ if not all(isinstance(level, str) for level in value):
227
+ raise GatewayError(400, "invalid", "reasoning_efforts is a list of names")
228
+ if creating and not value:
229
+ continue
230
+ out[key] = value
231
+ if creating and not out.get("model"):
232
+ raise GatewayError(400, "invalid", "A model needs the model id the endpoint expects")
233
+ return out
234
+
235
+
236
+ def model_view(model: ModelInfo, index: int) -> dict[str, Any]:
237
+ """Return one model as a page shows it. The token is never part of it: the file keeps it, the page only writes it."""
238
+ return {
239
+ "name": model.name,
240
+ "model": model.model,
241
+ "display_model": model.display_model,
242
+ "default": index == 0,
243
+ "context_window": model.context_window,
244
+ "compact_percent": model.compact_percent,
245
+ "multimodal": model.multimodal,
246
+ "efforts": list(model.efforts),
247
+ "api": model.api,
248
+ "base_url": model.base_url,
249
+ "temperature": model.temperature,
250
+ "web_search": model.web_search,
251
+ "web_search_name": model.web_search_name,
252
+ "can_search": model.can_search,
253
+ }
254
+
255
+
256
+ @dataclass(slots=True)
257
+ class Meta:
258
+ """What was learned from a thread's stored conversation the last time it changed."""
259
+
260
+ stamp: tuple[int, int] | None = None
261
+ preview: str = ""
262
+ spoken: int = 0
263
+ updated: float = 0.0
264
+ opened: bool = False
265
+
266
+
267
+ class Gateway:
268
+ """The gateway's state and its rules. One per process."""
269
+
270
+ def __init__(
271
+ self,
272
+ backend: Backend,
273
+ settings: Settings,
274
+ *,
275
+ poll: float = POLL_SECONDS,
276
+ clock: Any = time.time,
277
+ ) -> None:
278
+ """Hold a backend; nothing is read until :meth:`start`."""
279
+ self.backend = backend
280
+ self.settings = settings
281
+ self.poll = poll
282
+ self.clock = clock
283
+ self._probes: dict[str, tuple[float, bool]] = {}
284
+ #: What the turn running in each session has said so far, by session: for a page that opens in the middle of it.
285
+ self._live: dict[str, dict[str, Any]] = {}
286
+ self._finishing: set[asyncio.Task[None]] = set()
287
+ self._turns = itertools.count(1)
288
+ #: What a session said when a direct turn of ours ended without an answer: shown in its chat until we write again.
289
+ self._notes: dict[str, list[str]] = {}
290
+ #: The pictures of the chats last read, by chat and then by name, oldest chat first.
291
+ self._pictures: dict[str, dict[str, tuple[bytes | None, str]]] = {}
292
+ #: The sessions we asked to see the turns of, so one that is no longer listed can be let go of.
293
+ self._watching: set[str] = set()
294
+ self.roots = tuple(root.expanduser().resolve() for root in settings.roots)
295
+ self._views: dict[str, dict[str, Any]] = {}
296
+ self._meta: dict[str, Meta] = {}
297
+ self._inbox: dict[str, deque[dict[str, Any]]] = {}
298
+ self._senders: dict[str, str] = {}
299
+ self._seen: dict[str, int] = self._load_seen()
300
+ self._created: set[str] = set()
301
+ self._sent: deque[float] = deque()
302
+ self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
303
+ self._lock = asyncio.Lock()
304
+ #: Serialises model edits: each is a read-modify-write of one file, so two at once would drop one of them.
305
+ self._models_lock = asyncio.Lock()
306
+ self._wake = asyncio.Event()
307
+ self._watcher: asyncio.Task[None] | None = None
308
+ self._reachable: bool | None = None
309
+ #: The last failed refresh, so a permanent fault does not append a
310
+ #: traceback every poll interval.
311
+ self._refresh_error: str | None = None
312
+
313
+ # -- life -------------------------------------------------------------------------------------
314
+ async def start(self, *, watch: bool = True) -> None:
315
+ """Join the broker and begin watching; a test that wants to say when the gateway looks passes ``watch=False``."""
316
+ await self.backend.start(self._received, self._wake_up)
317
+ self.backend.on_stream(self._streamed)
318
+ self.backend.observe(self._traffic)
319
+ await self.refresh()
320
+ if watch:
321
+ self._watcher = asyncio.ensure_future(self._watch())
322
+
323
+ async def close(self) -> None:
324
+ """Stop watching and leave the broker."""
325
+ self.backend.observe(None)
326
+ finishing = list(self._finishing)
327
+ for task in finishing:
328
+ task.cancel()
329
+ await asyncio.gather(*finishing, return_exceptions=True)
330
+ watcher, self._watcher = self._watcher, None
331
+ if watcher is not None:
332
+ watcher.cancel()
333
+ await asyncio.gather(watcher, return_exceptions=True)
334
+ await self.backend.close()
335
+
336
+ def _wake_up(self) -> None:
337
+ self._wake.set()
338
+
339
+ async def _watch(self) -> None:
340
+ while True:
341
+ await self._poll_once()
342
+ with contextlib.suppress(asyncio.TimeoutError):
343
+ await asyncio.wait_for(self._wake.wait(), self.poll)
344
+ self._wake.clear()
345
+ await asyncio.sleep(0.05)
346
+
347
+ async def _poll_once(self) -> None:
348
+ """Take one refresh step, recording only a failure transition and its recovery."""
349
+ try:
350
+ await self.refresh()
351
+ except asyncio.CancelledError:
352
+ raise
353
+ except Exception as error: # noqa: BLE001 - a look that fails is tried again, and the page keeps what it has
354
+ try:
355
+ reason = f"{type(error).__name__}: {error}"
356
+ except Exception: # noqa: BLE001 - an unprintable error still has a type
357
+ reason = type(error).__name__
358
+ if reason != self._refresh_error:
359
+ logger.exception("refresh.failed")
360
+ self._refresh_error = reason
361
+ else:
362
+ if self._refresh_error is not None:
363
+ logger.info("refresh.recovered")
364
+ self._refresh_error = None
365
+
366
+ # -- what is there ----------------------------------------------------------------------------
367
+ async def refresh(self) -> None:
368
+ """Look at the daemons, the roster and the stored conversations once, and tell the pages what moved."""
369
+ async with self._lock:
370
+ daemons = {item.session_id: item for item in await asyncio.to_thread(self.backend.daemons)}
371
+ mine = self.backend.gateway_id
372
+ peers = {item.session_id: item for item in self.backend.peers() if item.session_id != mine}
373
+ for session_id in peers:
374
+ if session_id not in self._watching:
375
+ self._watching.add(session_id)
376
+ self.backend.watch(session_id) # costs a session nothing until it works
377
+ for gone in self._watching - peers.keys():
378
+ self._watching.discard(gone)
379
+ self.backend.unwatch(gone) # every run is a new id: they would add up, and the broker caps them
380
+ order = list(dict.fromkeys([*daemons, *peers, *self._inbox]))
381
+ views: dict[str, dict[str, Any]] = {}
382
+ moved: list[str] = []
383
+ for session_id in order:
384
+ daemon, peer = daemons.get(session_id), peers.get(session_id)
385
+ meta = self._meta.setdefault(session_id, Meta())
386
+ workspace = self._workspace_of(daemon, peer)
387
+ stamp = self.backend.stamp(session_id, workspace) if workspace else None
388
+ if stamp != meta.stamp:
389
+ # A session that is working changes its file all the time and is read when it goes quiet, so a
390
+ # long conversation is not parsed every look; one nobody is looking at is not read at all.
391
+ # A daemon is only different when nobody is running it: its record outlives its process, so a
392
+ # dead or offline daemon still has a conversation to show, while a working one is a peer.
393
+ working = peer is not None and peer.status == "working"
394
+ reading = meta.opened or peer is None or not working
395
+ if reading:
396
+ await self._learn(session_id, workspace, stamp, meta, wanted=True)
397
+ moved.append(session_id)
398
+ views[session_id] = self._view(session_id, daemon, peer, meta)
399
+ expired = self._expire_live(peers)
400
+ for gone in {*self._meta, *self._probes, *self._notes} - views.keys():
401
+ self._meta.pop(gone, None)
402
+ self._probes.pop(gone, None)
403
+ self._notes.pop(gone, None)
404
+ reachable = self.backend.reachable
405
+ changed = views != self._views
406
+ self._views = views
407
+ if changed or reachable != self._reachable:
408
+ if reachable and self._reachable is False:
409
+ # Pieces of every turn were missed while the broker was away: a bubble resumed mid-answer would be a
410
+ # truncated one, so what is shown live is dropped and the next ``begin`` starts it again.
411
+ for lost in list(self._live):
412
+ del self._live[lost]
413
+ self._publish({"type": "stream", "id": lost, "event": {"kind": "end"}})
414
+ self._reachable = reachable
415
+ self._publish({"type": "threads", "threads": self.threads(), "status": self.status()})
416
+ for session_id in moved:
417
+ self._publish({"type": "thread", "id": session_id})
418
+ # After what was stored has been announced, as at the end of a turn that was heard: the page has the message
419
+ # before the live one goes.
420
+ for session_id in expired:
421
+ self._publish({"type": "stream", "id": session_id, "event": {"kind": "end"}})
422
+
423
+ @staticmethod
424
+ def _workspace_of(daemon: DaemonInfo | None, peer: Peer | None) -> str:
425
+ if daemon is not None and daemon.workspace:
426
+ return daemon.workspace
427
+ return peer.workspace if peer is not None else ""
428
+
429
+ async def _learn(
430
+ self, session_id: str, workspace: str, stamp: tuple[int, int] | None, meta: Meta, *, wanted: bool
431
+ ) -> None:
432
+ """Take in a thread's stored conversation: its last words and how much it has said to us."""
433
+ meta.stamp = stamp
434
+ meta.updated = stamp[0] / 1e9 if stamp else 0.0
435
+ if stamp is None or not wanted:
436
+ return
437
+ try:
438
+ messages = await self.backend.history(session_id, workspace)
439
+ except (SessionNotFoundError, ZettCodeError, OSError):
440
+ return
441
+ items = items_of(messages, self.backend.gateway_id, handle=self.backend.handle)
442
+ meta.preview = preview(items)
443
+ meta.spoken = spoken(items)
444
+ self._baseline(session_id, meta.spoken)
445
+
446
+ def _baseline(self, session_id: str, count: int) -> None:
447
+ """Count what a thread said before the page first saw it as read, unless we started the thread ourselves."""
448
+ if session_id not in self._seen:
449
+ self._seen[session_id] = 0 if session_id in self._created else count
450
+ self._save_seen()
451
+
452
+ def _view(self, session_id: str, daemon: DaemonInfo | None, peer: Peer | None, meta: Meta) -> dict[str, Any]:
453
+ """Return one thread as a page lists it."""
454
+ sender = self._senders.get(session_id, session_id[:8])
455
+ if daemon is not None:
456
+ kind = "daemon"
457
+ elif peer is not None:
458
+ kind = "external" if peer.kind == "external" else "session"
459
+ else:
460
+ kind = "external" if session_id.startswith("external:") else "session"
461
+ title = (daemon.title if daemon is not None else None) or (peer.title if peer is not None else "")
462
+ name = (daemon.name if daemon is not None else None) or title or (peer.handle if peer else "") or sender
463
+ stored = meta.stamp is not None
464
+ said = meta.spoken if stored else len(self._inbox.get(session_id, ()))
465
+ # A stored conversation that is new to us is read up to now (see ``_baseline``); what reached us in this run is
466
+ # all newer than the gateway.
467
+ unread = max(0, said - self._seen.get(session_id, said if stored else 0))
468
+ last = self._inbox[session_id][-1]["text"] if not stored and self._inbox.get(session_id) else ""
469
+ return {
470
+ "id": session_id,
471
+ "kind": kind,
472
+ "name": name,
473
+ "handle": peer.handle if peer is not None else "",
474
+ "title": title,
475
+ "workspace": self._workspace_of(daemon, peer),
476
+ "status": self._status(daemon, peer),
477
+ "online": peer is not None,
478
+ "model": daemon.model if daemon is not None else None,
479
+ "effort": daemon.effort if daemon is not None else None,
480
+ "permission": daemon.permission if daemon is not None else None,
481
+ "pid": daemon.pid if daemon is not None else 0,
482
+ "started_at": daemon.started_at if daemon is not None else 0.0,
483
+ "updated_at": meta.updated or (daemon.started_at if daemon is not None else 0.0),
484
+ "preview": meta.preview or _short(str(last)),
485
+ "unread": unread,
486
+ "direct": peer is not None and peer.direct,
487
+ "locked": peer is None and stored and self._locked(session_id, self._workspace_of(daemon, peer)),
488
+ }
489
+
490
+ def _locked(self, session_id: str, workspace: str) -> bool:
491
+ """Return whether another process has an unreachable session open, asked at most every few seconds."""
492
+ now = self.clock()
493
+ at, held = self._probes.get(session_id, (0.0, False))
494
+ if now - at < LOCK_PROBE_SECONDS:
495
+ return held
496
+ try:
497
+ held = bool(workspace) and self.backend.locked(session_id, workspace)
498
+ except Exception: # noqa: BLE001 - a probe that fails says nothing about the session
499
+ held = False
500
+ self._probes[session_id] = (now, held)
501
+ return held
502
+
503
+ async def recheck(self, session_id: str) -> dict[str, Any]:
504
+ """Ask again whether another process still has a session open, and return the thread as it is now."""
505
+ if session_id not in self._views:
506
+ raise GatewayError(404, "not_found", "No such session is listed")
507
+ self._probes.pop(session_id, None)
508
+ await self.refresh()
509
+ view = self._views.get(session_id) # it may have gone while we looked
510
+ if view is None:
511
+ raise GatewayError(404, "not_found", "No such session is listed")
512
+ return view
513
+
514
+ @staticmethod
515
+ def _status(daemon: DaemonInfo | None, peer: Peer | None) -> str:
516
+ """Return what a thread is doing: the roster's word when it is registered, the daemon's own otherwise."""
517
+ if peer is not None:
518
+ return "stopping" if daemon is not None and daemon.status == "stopping" else peer.status
519
+ if daemon is None:
520
+ return "offline"
521
+ return daemon.status if daemon.status in ("starting", "stopping", "dead") else "offline"
522
+
523
+ # -- what a page asks -------------------------------------------------------------------------
524
+ def threads(self) -> list[dict[str, Any]]:
525
+ """Return the threads in two groups, the online ones first, each with the most recently active first."""
526
+ return sorted(self._views.values(), key=lambda view: (not view["online"], -float(view["updated_at"])))
527
+
528
+ def status(self) -> dict[str, Any]:
529
+ """Return the gateway itself: who it is, whether the broker is there, and how much it may still send."""
530
+ limits = self.backend.limits
531
+ used = self._spent(limits.rate_window)
532
+ return {
533
+ "version": __version__,
534
+ "gateway": {"id": self.backend.gateway_id, "handle": self.backend.handle},
535
+ "broker": {"reachable": self.backend.reachable},
536
+ "limits": {
537
+ "max_text": limits.max_text,
538
+ "rate_limit": limits.rate_limit,
539
+ "rate_window": limits.rate_window,
540
+ "max_images": limits.max_images,
541
+ "max_image_bytes": limits.max_image_bytes,
542
+ "max_images_bytes": limits.max_images_bytes,
543
+ "used": used,
544
+ },
545
+ }
546
+
547
+ def config(self) -> dict[str, Any]:
548
+ """Return what the new-daemon form and the settings pages need to know."""
549
+ try:
550
+ models = self.backend.models()
551
+ except ValueError as error:
552
+ raise GatewayError(500, "config", str(error)) from error
553
+ return {
554
+ "version": __version__,
555
+ "roots": [str(root) for root in self.roots],
556
+ "allow_full": self.settings.allow_full,
557
+ "permissions": ["deny", "safe", "full"],
558
+ "models": [model_view(model, index) for index, model in enumerate(models)],
559
+ }
560
+
561
+ async def add_model(self, data: Mapping[str, Any]) -> dict[str, Any]:
562
+ """Write a new model into the configuration file and return the list."""
563
+ entry = model_fields(data, creating=True)
564
+ return await self._edit_models(lambda: self.backend.add_model(entry))
565
+
566
+ async def change_model(self, name: str, data: Mapping[str, Any]) -> dict[str, Any]:
567
+ """Change one model of the configuration file; what is not named stays, and so does the token."""
568
+ changes = model_fields(data, creating=False)
569
+ if not changes:
570
+ raise GatewayError(400, "invalid", "Name at least one thing to change")
571
+ return await self._edit_models(lambda: self.backend.update_model(name, changes))
572
+
573
+ async def remove_model(self, name: str) -> dict[str, Any]:
574
+ """Remove one model from the configuration file."""
575
+ return await self._edit_models(lambda: self.backend.remove_model(name))
576
+
577
+ async def _edit_models(self, edit: Callable[[], list[ModelInfo]]) -> dict[str, Any]:
578
+ """Run a configuration edit off the loop and answer with the list it left.
579
+
580
+ A daemon reads the file when it starts, so a change reaches the next one and touches no running session. A
581
+ refusal is the file's own sentence, which names the wrong key, and the file is left as it was. Edits made
582
+ through this gateway run one at a time; one made by a terminal's ``/config`` at the same moment is not seen.
583
+ """
584
+ try:
585
+ async with self._models_lock:
586
+ models = await asyncio.to_thread(edit)
587
+ except ValueError as error:
588
+ raise GatewayError(400, "invalid", str(error)) from error
589
+ except OSError as error:
590
+ raise GatewayError(500, "config", f"The configuration file could not be written: {error}") from error
591
+ return {"models": [model_view(model, index) for index, model in enumerate(models)]}
592
+
593
+ async def thread(self, session_id: str, *, everything: bool = False) -> dict[str, Any]:
594
+ """Return a thread and its conversation as chat items."""
595
+ view = self._views.get(session_id)
596
+ if view is None:
597
+ await self.refresh()
598
+ view = self._views.get(session_id)
599
+ if view is None:
600
+ raise GatewayError(404, "not_found", "No such session is listed")
601
+ meta = self._meta.setdefault(session_id, Meta())
602
+ meta.opened = True
603
+ items: list[dict[str, Any]] = []
604
+ stored = False
605
+ if view["workspace"]:
606
+ try:
607
+ messages = await self.backend.history(session_id, view["workspace"])
608
+ except (SessionNotFoundError, ZettCodeError, OSError):
609
+ messages = []
610
+ else:
611
+ stored = True
612
+ self._keep_pictures(session_id, messages)
613
+ items = items_of(messages, self.backend.gateway_id, handle=self.backend.handle)
614
+ if stored:
615
+ meta.preview, meta.spoken = preview(items), spoken(items)
616
+ self._baseline(session_id, meta.spoken)
617
+ if not items:
618
+ items = [dict(item) for item in self._inbox.get(session_id, ())]
619
+ total = len(items)
620
+ if not everything and total > ITEM_LIMIT:
621
+ items = items[-ITEM_LIMIT:]
622
+ # A turn that ended without an answer is not in the stored conversation, and the person is told here.
623
+ notes = [{"kind": "note", "text": text} for text in self._notes.get(session_id, ())]
624
+ items.extend(notes)
625
+ total += len(notes)
626
+ return {
627
+ "thread": self._views.get(session_id, view),
628
+ "items": items,
629
+ "total": total,
630
+ "truncated": total > len(items),
631
+ "live": self._live_view(session_id),
632
+ }
633
+
634
+ async def read(self, session_id: str) -> None:
635
+ """Mark everything a thread has said as read."""
636
+ view = self._views.get(session_id)
637
+ if view is None:
638
+ raise GatewayError(404, "not_found", "No such session is listed")
639
+ meta = self._meta.setdefault(session_id, Meta())
640
+ stored = meta.stamp is not None
641
+ self._seen[session_id] = meta.spoken if stored else len(self._inbox.get(session_id, ()))
642
+ self._save_seen()
643
+ await self.refresh()
644
+
645
+ # -- sending ----------------------------------------------------------------------------------
646
+ async def send(
647
+ self, session_id: str, text: str, *, reply: bool | None = None, images: object = None
648
+ ) -> dict[str, Any]:
649
+ """Send a message to a thread and return who took it and what became of it.
650
+
651
+ Args:
652
+ session_id: The thread.
653
+ text: What the person typed; it may be empty when pictures go with it.
654
+ reply: Whether to ask the agent to answer with ``send_message``. By default a daemon is asked, since it
655
+ has nobody to answer to otherwise, and a session with a person at it is not.
656
+ images: Pictures to send with it, as a page sends them (``[{media_type, data}]``, base64). Only a session
657
+ that runs the text as its own prompt and whose model reads pictures takes them.
658
+
659
+ Raises:
660
+ GatewayError: The message is empty or too long, the thread is not there, this gateway has used up what
661
+ the broker allows, or the broker refused. A reason of ``timeout`` or ``lost`` means the message may
662
+ or may not have arrived (``uncertain``), and nothing here sends it again.
663
+ """
664
+ view = self._views.get(session_id)
665
+ if view is None:
666
+ raise GatewayError(404, "not_found", "No such session is listed")
667
+ text = text.replace("\r\n", "\n").strip()
668
+ pictures = read_pictures(images, self.backend.limits)
669
+ if not text and not pictures:
670
+ raise GatewayError(400, "empty", "A message needs some text")
671
+ if not view["online"]:
672
+ raise GatewayError(409, "offline", f"{view['name']} is not connected, so it cannot be written to")
673
+ # A session that runs the text as its own prompt answers by itself, and the broker brings the answer back; one that
674
+ # does not is a colleague to be asked, and a daemon has nobody to answer to otherwise.
675
+ direct = bool(view["direct"])
676
+ if pictures:
677
+ self._check_pictures(view, direct)
678
+ asked = False if direct else (view["kind"] == "daemon" if reply is None else reply)
679
+ body = with_reply_request(text, self.backend.gateway_id) if asked else text
680
+ limits = self.backend.limits
681
+ if len(body) > limits.max_text:
682
+ room = max(0, limits.max_text - (len(body) - len(text)))
683
+ raise GatewayError(413, "too_long", f"A message is limited to {room} characters here", limit=room)
684
+ self._spend(limits.rate_limit, limits.rate_window)
685
+ self._notes.pop(session_id, None)
686
+ try:
687
+ delivered = await self.backend.send(session_id, body, direct=direct, images=pictures)
688
+ except SendError as error:
689
+ raise GatewayError(
690
+ SEND_STATUS.get(error.reason, 400),
691
+ error.reason,
692
+ str(error),
693
+ uncertain=error.reason in ("timeout", "lost"),
694
+ ) from error
695
+ self._wake_up()
696
+ return {"to": delivered.to, "disposition": delivered.disposition, "direct": delivered.direct}
697
+
698
+ def _check_pictures(self, view: Mapping[str, Any], direct: bool) -> None:
699
+ """Refuse pictures for a session that cannot use them, in words a person can act on, before anything is sent."""
700
+ if not direct:
701
+ raise GatewayError(
702
+ 400,
703
+ "no_pictures",
704
+ f"{view['name']} cannot be sent a picture: it does not run a chat's text as its own prompt",
705
+ )
706
+ name = view.get("model")
707
+ known = next((model for model in self.backend.models() if model.name == name), None)
708
+ if known is not None and not known.multimodal:
709
+ raise GatewayError(
710
+ 400,
711
+ "no_pictures",
712
+ f"{name} does not accept pictures; send the text alone or start a daemon on a model that does",
713
+ )
714
+
715
+ async def picture(self, session_id: str, picture_id: str) -> tuple[bytes, str]:
716
+ """Return a picture of a thread's stored conversation by the name its chat item gave it: its bytes and type.
717
+
718
+ Reading a conversation decodes every picture in it, so what a chat was last read with is kept — the names of its
719
+ pictures always, and their bytes while they weigh :data:`PICTURE_BYTES` or less — and a page fetching its pictures
720
+ one by one does not read the file once for each. A chat that has been read is the last word on what it holds: a
721
+ name it does not hold is answered without reading anything, so asking for names that are not there costs nothing.
722
+ """
723
+ view = self._views.get(session_id)
724
+ if view is None or not view["workspace"] or not PICTURE_NAME.match(picture_id):
725
+ raise GatewayError(404, "not_found", "No such picture")
726
+ names = self._pictures.get(session_id)
727
+ messages: Sequence[Message] | None = None
728
+ if names is None:
729
+ messages = await self._history_of(session_id, view["workspace"])
730
+ names = self._keep_pictures(session_id, messages)
731
+ found = names.get(picture_id)
732
+ # Whatever the file says, only a type a model takes is served, and the page never lets a browser guess another.
733
+ if found is None or found[1] not in IMAGE_TYPES:
734
+ raise GatewayError(404, "not_found", "No such picture")
735
+ data, kind = found
736
+ if data is None: # a chat too heavy to keep in memory: its bytes are read when they are asked for
737
+ if messages is None:
738
+ messages = await self._history_of(session_id, view["workspace"])
739
+ data = next(
740
+ (
741
+ picture.data
742
+ for message in messages
743
+ for picture in message.pictures
744
+ if picture_name(picture.data) == picture_id
745
+ ),
746
+ None,
747
+ )
748
+ if data is None: # it was in the conversation a moment ago and is not now
749
+ raise GatewayError(404, "not_found", "No such picture")
750
+ return data, kind
751
+
752
+ async def _history_of(self, session_id: str, workspace: str) -> Sequence[Message]:
753
+ try:
754
+ return await self.backend.history(session_id, workspace)
755
+ except (SessionNotFoundError, ZettCodeError, OSError):
756
+ raise GatewayError(404, "not_found", "No such picture") from None
757
+
758
+ def _keep_pictures(self, session_id: str, messages: Sequence[Message]) -> dict[str, tuple[bytes | None, str]]:
759
+ """Remember the pictures of a conversation by name, for the last few chats, and return them.
760
+
761
+ A chat is remembered even when it has none, so that asking it for a picture is answered at once. Its bytes are
762
+ kept only when they come to :data:`PICTURE_BYTES` or less, so a conversation of large pictures cannot pin them.
763
+ """
764
+ found = {
765
+ picture_name(picture.data): (picture.data, picture.media_type)
766
+ for message in messages
767
+ for picture in message.pictures
768
+ }
769
+ light = sum(len(data) for data, _ in found.values()) <= PICTURE_BYTES
770
+ names: dict[str, tuple[bytes | None, str]] = {
771
+ name: (data if light else None, kind) for name, (data, kind) in found.items()
772
+ }
773
+ self._pictures.pop(session_id, None)
774
+ self._pictures[session_id] = names
775
+ while len(self._pictures) > PICTURE_CHATS:
776
+ self._pictures.pop(next(iter(self._pictures)))
777
+ return names
778
+
779
+ def _spent(self, window: float) -> int:
780
+ now = self.clock()
781
+ while self._sent and now - self._sent[0] > window:
782
+ self._sent.popleft()
783
+ return len(self._sent)
784
+
785
+ def _spend(self, limit: int, window: float) -> None:
786
+ """Count a send against what the broker allows, and refuse it here when that is used up."""
787
+ if self._spent(window) >= limit:
788
+ wait = max(1, int(window - (self.clock() - self._sent[0])) + 1)
789
+ raise GatewayError(
790
+ 429, "rate", f"At most {limit} messages a minute can be sent; try again in {wait}s", retry_after=wait
791
+ )
792
+ self._sent.append(self.clock())
793
+
794
+ def _received(self, message: IncomingMessage) -> Receipt:
795
+ """Take a message a session sent to this gateway: keep it for a thread that has no stored conversation."""
796
+ if is_direct_notice(message.text):
797
+ # Said about a turn of ours that ended without an answer: a note in that chat, not a message to be read (the
798
+ # conversation is stored, and a message here would show the line twice). A few are kept: any session can send one.
799
+ notes = self._notes.setdefault(message.sender_id, [])
800
+ notes[:] = [*notes, message.text][-NOTE_LIMIT:]
801
+ self._senders[message.sender_id] = message.sender
802
+ self._publish({"type": "thread", "id": message.sender_id})
803
+ # Told like any message that arrives, so a failure is noticed when the chat is not open: it adds no item to
804
+ # the conversation (that is the note), only the notification.
805
+ self._publish(
806
+ {
807
+ "type": "inbound",
808
+ "id": message.sender_id,
809
+ "sender": message.sender,
810
+ "text": _short(message.text, 140),
811
+ }
812
+ )
813
+ return Receipt(True, disposition="delivered")
814
+ box = self._inbox.setdefault(message.sender_id, deque(maxlen=INBOX_LIMIT))
815
+ box.append({"kind": "in", "text": message.text, "via": message.via, "sender": message.sender})
816
+ self._senders[message.sender_id] = message.sender
817
+ self._publish(
818
+ {
819
+ "type": "inbound",
820
+ "id": message.sender_id,
821
+ "sender": message.sender,
822
+ "text": _short(message.text, 140),
823
+ }
824
+ )
825
+ self._wake_up()
826
+ return Receipt(True, disposition="delivered")
827
+
828
+ # -- daemons ----------------------------------------------------------------------------------
829
+ async def spawn(self, form: Mapping[str, Any]) -> dict[str, Any]:
830
+ """Start a daemon as the form says and return its thread, and how its first message went if it had one."""
831
+ workspace = self.workspace(str(form.get("workspace") or ""))
832
+ permission = str(form.get("permission") or "safe")
833
+ if permission not in ("deny", "safe", "full"):
834
+ raise GatewayError(400, "invalid", "The shell level is deny, safe or full")
835
+ if permission == "full" and not self.settings.allow_full:
836
+ raise GatewayError(
837
+ 403, "forbidden", "Starting a daemon with the full shell level was not allowed when the gateway started"
838
+ )
839
+ model, effort = self._model_and_effort(form.get("model"), form.get("effort"))
840
+ resume = form.get("resume") or None
841
+ if resume is not None and not (isinstance(resume, str) and SESSION_ID.match(resume)):
842
+ raise GatewayError(400, "invalid", "A session to resume is named by its id")
843
+ try:
844
+ info = await self.backend.spawn(
845
+ workspace,
846
+ model=model,
847
+ effort=effort,
848
+ resume=resume,
849
+ name=_label(form.get("name")),
850
+ permission=permission,
851
+ )
852
+ except DaemonStartError as error:
853
+ raise GatewayError(502, "start_failed", str(error)) from error
854
+ except SessionLockedError as error:
855
+ raise GatewayError(409, "locked", str(error)) from error
856
+ except ConfigurationError as error:
857
+ raise GatewayError(400, "invalid", str(error)) from error
858
+ self._created.add(info.session_id)
859
+ await self.refresh()
860
+ answer: dict[str, Any] = {"thread": self._views.get(info.session_id)}
861
+ first = str(form.get("message") or "").strip()
862
+ if first:
863
+ answer["first"] = await self._first_message(info.session_id, first)
864
+ return answer
865
+
866
+ async def _first_message(self, session_id: str, text: str) -> dict[str, Any]:
867
+ """Send the first message of a new daemon once the broker lists it; a failure is reported, not raised."""
868
+ for _ in range(50):
869
+ if any(peer.session_id == session_id for peer in self.backend.peers()):
870
+ break
871
+ await asyncio.sleep(0.1)
872
+ await self.refresh()
873
+ try:
874
+ return {"ok": True, **await self.send(session_id, text)}
875
+ except GatewayError as error:
876
+ return {"ok": False, **error.to_dict()}
877
+
878
+ async def stop(self, session_id: str, *, force: bool = False) -> dict[str, str]:
879
+ """Stop a daemon; it stays listed as offline, with its conversation, and a working one needs ``force``."""
880
+ if not SESSION_ID.match(session_id):
881
+ raise GatewayError(400, "invalid", "A daemon is named by its session id")
882
+ try:
883
+ said = await self.backend.stop(session_id, force=force)
884
+ except SessionNotFoundError as error:
885
+ raise GatewayError(404, "not_found", str(error)) from error
886
+ except ConfigurationError as error:
887
+ raise GatewayError(409, "busy", str(error)) from error
888
+ await self.refresh()
889
+ return {"result": said}
890
+
891
+ async def delete(self, session_id: str) -> dict[str, str]:
892
+ """Remove a stopped daemon for good: its record and its conversation."""
893
+ if not SESSION_ID.match(session_id):
894
+ raise GatewayError(400, "invalid", "A daemon is named by its session id")
895
+ try:
896
+ said = await self.backend.delete(session_id)
897
+ except SessionNotFoundError as error:
898
+ raise GatewayError(404, "not_found", str(error)) from error
899
+ except ConfigurationError as error:
900
+ raise GatewayError(409, "busy", str(error)) from error
901
+ self._forget(session_id)
902
+ await self.refresh()
903
+ return {"result": said}
904
+
905
+ def _forget(self, session_id: str) -> None:
906
+ """Drop what the gateway holds about a session that was deleted, so it is not listed again."""
907
+ self._inbox.pop(session_id, None)
908
+ self._senders.pop(session_id, None)
909
+ self._pictures.pop(session_id, None)
910
+ self._notes.pop(session_id, None)
911
+ self._live.pop(session_id, None)
912
+ if self._seen.pop(session_id, None) is not None:
913
+ self._save_seen()
914
+
915
+ async def log(self, session_id: str) -> dict[str, Any]:
916
+ """Return the end of a daemon's own log."""
917
+ try:
918
+ text = await asyncio.to_thread(self.backend.log, session_id, 300)
919
+ except SessionNotFoundError as error:
920
+ raise GatewayError(404, "not_found", str(error)) from error
921
+ return {"text": text}
922
+
923
+ def _model_and_effort(self, model: object, effort: object) -> tuple[str | None, str | None]:
924
+ models = self.backend.models()
925
+ chosen = str(model) if model else None
926
+ if chosen is None:
927
+ if effort:
928
+ chosen_info = models[0] if models else None
929
+ else:
930
+ return None, None
931
+ else:
932
+ chosen_info = next((item for item in models if item.name == chosen), None)
933
+ if chosen_info is None:
934
+ raise GatewayError(400, "invalid", f"No model is called {chosen!r}")
935
+ level = str(effort) if effort else None
936
+ if level is not None and (chosen_info is None or level not in chosen_info.efforts):
937
+ raise GatewayError(400, "invalid", f"{chosen or 'The default model'} has no reasoning level {level!r}")
938
+ return chosen, level
939
+
940
+ # -- where agents may work --------------------------------------------------------------------
941
+ def _inside(self, path: Path) -> bool:
942
+ return any(path == root or root in path.parents for root in self.roots)
943
+
944
+ def workspace(self, text: str) -> Path:
945
+ """Return the directory a daemon may work in, or refuse: it exists, and lies within a configured root."""
946
+ if not text.strip():
947
+ raise GatewayError(400, "invalid", "A daemon needs a workspace")
948
+ if not self.roots:
949
+ raise GatewayError(403, "forbidden", "The gateway was started with no directories agents may work in")
950
+ path = Path(text).expanduser().resolve()
951
+ if not self._inside(path):
952
+ raise GatewayError(403, "forbidden", "That directory is outside the ones this gateway may start agents in")
953
+ if not path.is_dir():
954
+ raise GatewayError(400, "invalid", f"No such directory: {path}")
955
+ return path
956
+
957
+ def browse(self, text: str) -> dict[str, Any]:
958
+ """List the directories under a path, for the page's folder picker.
959
+
960
+ With no path, the person's home directory when it is allowed, and the roots themselves when it is not.
961
+ """
962
+ if not text.strip():
963
+ home = Path.home().resolve()
964
+ if self._inside(home):
965
+ return self.browse(str(home))
966
+ return {"path": "", "parent": None, "dirs": [{"name": str(root), "path": str(root)} for root in self.roots]}
967
+ path = self.workspace(text)
968
+ try:
969
+ children = sorted(
970
+ (child for child in path.iterdir() if child.is_dir() and not child.name.startswith(".")),
971
+ key=lambda child: child.name.lower(),
972
+ )
973
+ except OSError as error:
974
+ raise GatewayError(403, "forbidden", f"That directory cannot be read: {error.strerror or error}") from error
975
+ if path == path.parent:
976
+ parent: str | None = None # the top of the filesystem
977
+ elif path in self.roots:
978
+ parent = "" # back to the list of roots
979
+ else:
980
+ parent = str(path.parent)
981
+ return {
982
+ "path": str(path),
983
+ "parent": parent,
984
+ "dirs": [{"name": child.name, "path": str(child)} for child in children[:300]],
985
+ }
986
+
987
+ # -- events -----------------------------------------------------------------------------------
988
+ def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
989
+ """Return a queue of events, starting with the state of everything, for one connected page."""
990
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
991
+ queue.put_nowait({"type": "threads", "threads": self.threads(), "status": self.status()})
992
+ self._subscribers.add(queue)
993
+ return queue
994
+
995
+ def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
996
+ """Forget a page that has gone."""
997
+ self._subscribers.discard(queue)
998
+
999
+ # -- a turn as it happens ---------------------------------------------------------------------
1000
+ def _streamed(self, event: StreamEvent) -> None:
1001
+ """Take one thing a watched session's turn said: keep it for a page that opens later, and pass it on."""
1002
+ session_id = event.session_id
1003
+ if session_id == self.backend.gateway_id:
1004
+ return
1005
+ live = self._live.get(session_id)
1006
+ kind = event.kind
1007
+ if kind == "begin":
1008
+ live = self._live[session_id] = self._new_turn()
1009
+ elif kind == "end":
1010
+ self._finish_later(session_id, live["turn"] if live is not None else None)
1011
+ return
1012
+ elif live is None:
1013
+ live = self._live[session_id] = self._new_turn()
1014
+ live["at"] = self.clock()
1015
+ wire: dict[str, Any] = {"kind": kind}
1016
+ if kind == "text":
1017
+ live["thinking"] = False
1018
+ live["text"] = (live["text"] + event.text)[-LIVE_TEXT_LIMIT:]
1019
+ wire["text"] = event.text
1020
+ elif kind == "thinking":
1021
+ live["thinking"] = True
1022
+ elif kind == "tool":
1023
+ live["thinking"] = False
1024
+ live["tools"] = [
1025
+ *live["tools"],
1026
+ {"id": event.call_id, "name": event.name, "brief": event.brief, "ok": None},
1027
+ ][-LIVE_TOOL_LIMIT:]
1028
+ wire.update(id=event.call_id, name=event.name, brief=event.brief)
1029
+ elif kind == "tool_end":
1030
+ for tool in live["tools"]:
1031
+ if tool["id"] == event.call_id:
1032
+ tool["ok"] = event.ok
1033
+ wire.update(id=event.call_id, ok=event.ok)
1034
+ self._publish({"type": "stream", "id": session_id, "event": wire})
1035
+
1036
+ def _new_turn(self) -> dict[str, Any]:
1037
+ """Return the state of a turn that has just been seen to begin, marked so its end is told from the next one's."""
1038
+ return {"turn": next(self._turns), "text": "", "thinking": False, "tools": [], "at": self.clock()}
1039
+
1040
+ def _live_view(self, session_id: str) -> dict[str, Any] | None:
1041
+ """Return the turn running in a session as a page reads it (without the gateway's own bookkeeping)."""
1042
+ live = self._live.get(session_id)
1043
+ return None if live is None else {key: value for key, value in live.items() if key not in ("at", "turn")}
1044
+
1045
+ def _finish_later(self, session_id: str, turn: int | None) -> None:
1046
+ task = asyncio.ensure_future(self._finish(session_id, turn))
1047
+ self._finishing.add(task)
1048
+ task.add_done_callback(self._finishing.discard)
1049
+
1050
+ async def _finish(self, session_id: str, turn: int | None) -> None:
1051
+ """The turn ended: read what it wrote first, so the page has the stored message before the live one goes.
1052
+
1053
+ Reading yields, and the session may begin its next turn meanwhile: that one is not this one's to end, and one
1054
+ that was already dropped (its session vanished) has been ended for the pages.
1055
+ """
1056
+ with contextlib.suppress(Exception):
1057
+ await self.refresh()
1058
+ live = self._live.get(session_id)
1059
+ if live is not None and live["turn"] == turn:
1060
+ del self._live[session_id]
1061
+ self._publish({"type": "stream", "id": session_id, "event": {"kind": "end"}})
1062
+ self._publish({"type": "thread", "id": session_id})
1063
+
1064
+ def _expire_live(self, peers: dict[str, Peer]) -> list[str]:
1065
+ """Drop a live turn whose session has gone, or has been idle and silent: its end was never heard.
1066
+
1067
+ Returns the sessions it dropped, for the caller to tell the pages once what was stored has been announced.
1068
+ """
1069
+ now = self.clock()
1070
+ expired: list[str] = []
1071
+ for session_id, live in list(self._live.items()):
1072
+ peer = peers.get(session_id)
1073
+ if peer is None or (peer.status != "working" and now - float(live["at"]) > LIVE_STALE_SECONDS):
1074
+ del self._live[session_id]
1075
+ expired.append(session_id)
1076
+ return expired
1077
+
1078
+ # -- the graph --------------------------------------------------------------------------------
1079
+ def _traffic(self, line: dict[str, Any]) -> None:
1080
+ """Tell the pages what the broker saw: a message that passed, or that who is here (or the groups) changed."""
1081
+ if line.get("kind") == "message" and isinstance(line.get("message"), dict):
1082
+ self._publish({"type": "traffic", "message": line["message"]})
1083
+ elif line.get("kind") == "graph":
1084
+ self._publish({"type": "graph"})
1085
+
1086
+ async def graph(self) -> dict[str, Any]:
1087
+ """Return who is connected, the groups, how much each pair has said and the latest messages."""
1088
+ try:
1089
+ return await self.backend.graph()
1090
+ except SendError as error:
1091
+ raise GatewayError(503, error.reason, str(error)) from error
1092
+
1093
+ async def older(self, before: object, limit: object, group: object = None) -> dict[str, Any]:
1094
+ """Return the stored messages before id ``before``, oldest first: what a feed loads as it is scrolled back.
1095
+
1096
+ With ``group`` they are that group's own messages (what its chat window shows), and ``before`` may be empty or
1097
+ ``0`` for its newest.
1098
+ """
1099
+ try:
1100
+ first = int(before) if before not in (None, "") else 0 # type: ignore[call-overload]
1101
+ count = int(limit) if limit not in (None, "") else 50 # type: ignore[call-overload]
1102
+ except (TypeError, ValueError):
1103
+ raise GatewayError(400, "invalid", "before is a message id, limit a number") from None
1104
+ name = str(group).lstrip("#") if group else ""
1105
+ if first < 1 and not name:
1106
+ raise GatewayError(400, "invalid", "before is a message id")
1107
+ try:
1108
+ return await self.backend.older(max(first, 0), max(1, min(count, 200)), name)
1109
+ except SendError as error:
1110
+ raise GatewayError(404 if error.reason == "unknown" else 503, error.reason, str(error)) from error
1111
+
1112
+ async def send_group(self, name: str, text: str) -> dict[str, Any]:
1113
+ """Write to a group chat as the person at the gateway, joining the group first when this gateway is not in it.
1114
+
1115
+ Raises:
1116
+ GatewayError: The text is empty or too long, the group is not there, nobody else in it is connected, this
1117
+ gateway has used up what the broker allows, or the broker refused.
1118
+ """
1119
+ name = name.lstrip("#")
1120
+ text = text.replace("\r\n", "\n").strip()
1121
+ if not text:
1122
+ raise GatewayError(400, "empty", "A message needs some text")
1123
+ limits = self.backend.limits
1124
+ if len(text) > limits.max_text:
1125
+ raise GatewayError(
1126
+ 413, "too_long", f"A message is limited to {limits.max_text} characters", limit=limits.max_text
1127
+ )
1128
+ self._spend(limits.rate_limit, limits.rate_window)
1129
+ try:
1130
+ try:
1131
+ delivered = await self.backend.send_group(name, text)
1132
+ except SendError as error:
1133
+ if error.reason != "denied":
1134
+ raise
1135
+ # Not a member: the person at the gateway is writing in this group, so they are put in it.
1136
+ await self.backend.join_group(name)
1137
+ delivered = await self.backend.send_group(name, text)
1138
+ except SendError as error:
1139
+ raise GatewayError(
1140
+ 404 if error.reason == "unknown" else SEND_STATUS.get(error.reason, 400),
1141
+ error.reason,
1142
+ str(error),
1143
+ uncertain=error.reason in ("timeout", "lost"),
1144
+ ) from error
1145
+ self._wake_up()
1146
+ return {"delivered": [{"to": item.to, "disposition": item.disposition} for item in delivered]}
1147
+
1148
+ async def timeline(self, since: object, until: object, limit: object) -> dict[str, Any]:
1149
+ """Return the stored messages between two times, for the page to replay. Nothing is sent again.
1150
+
1151
+ Args:
1152
+ since: Epoch seconds the stretch starts at; an hour before ``until`` when it is not given.
1153
+ until: Epoch seconds it ends at; now when it is not given.
1154
+ limit: The most messages to return (the newest of the stretch), at most 20000. The answer says ``truncated``
1155
+ when the stretch held more, which a replay must say rather than draw a flow that is missing its start.
1156
+ """
1157
+ try:
1158
+ end = float(until) if until not in (None, "") else float(self.clock()) # type: ignore[arg-type]
1159
+ start = float(since) if since not in (None, "") else end - 3600 # type: ignore[arg-type]
1160
+ count = int(limit) if limit not in (None, "") else 500 # type: ignore[call-overload]
1161
+ except (TypeError, ValueError):
1162
+ raise GatewayError(400, "invalid", "since and until are epoch seconds, limit a number") from None
1163
+ if start > end:
1164
+ raise GatewayError(400, "invalid", "since is after until")
1165
+ try:
1166
+ return await self.backend.timeline(start, end, max(1, min(count, 20_000)))
1167
+ except SendError as error:
1168
+ raise GatewayError(503, error.reason, str(error)) from error
1169
+
1170
+ def _publish(self, event: dict[str, Any]) -> None:
1171
+ for queue in list(self._subscribers):
1172
+ try:
1173
+ queue.put_nowait(event)
1174
+ except asyncio.QueueFull:
1175
+ # A page that stopped reading is dropped, and told to go: its socket is closed so that it reconnects and
1176
+ # asks for everything again, instead of sitting on stale data believing it is live.
1177
+ self._subscribers.discard(queue)
1178
+ with contextlib.suppress(asyncio.QueueEmpty, asyncio.QueueFull):
1179
+ queue.get_nowait()
1180
+ queue.put_nowait({"type": "close"})
1181
+
1182
+ # -- what the page remembers ------------------------------------------------------------------
1183
+ def _seen_path(self) -> Path:
1184
+ return self.settings.directory / "ui.json"
1185
+
1186
+ def _load_seen(self) -> dict[str, int]:
1187
+ try:
1188
+ data = json.loads(self._seen_path().read_text(encoding="utf-8"))
1189
+ except (OSError, ValueError):
1190
+ return {}
1191
+ seen = data.get("seen") if isinstance(data, dict) else None
1192
+ if not isinstance(seen, dict):
1193
+ return {}
1194
+ return {key: value for key, value in seen.items() if isinstance(key, str) and isinstance(value, int)}
1195
+
1196
+ def _save_seen(self) -> None:
1197
+ """Write which messages have been read; a failure only costs the marks, so it is not raised."""
1198
+ path = self._seen_path()
1199
+ temporary = path.with_name(f".ui.{os.getpid()}.tmp")
1200
+ try:
1201
+ path.parent.mkdir(parents=True, exist_ok=True)
1202
+ temporary.write_text(json.dumps({"seen": self._seen}), encoding="utf-8")
1203
+ os.replace(temporary, path)
1204
+ except OSError:
1205
+ pass
1206
+
1207
+
1208
+ def _short(text: str, limit: int = 90) -> str:
1209
+ line = " ".join(text.split())
1210
+ return line if len(line) <= limit else line[: limit - 1] + "\u2026"
1211
+
1212
+
1213
+ def _label(value: object) -> str | None:
1214
+ """Return a daemon's label cleaned of control characters and cut to size, or ``None`` when nothing is left."""
1215
+ if not isinstance(value, str):
1216
+ return None
1217
+ kept = "".join(char for char in value if unicodedata.category(char)[0] != "C" or char == " ")
1218
+ line = " ".join(kept.split())[:NAME_LIMIT]
1219
+ return line or None