semiont 0.6.12.dev2__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 (66) hide show
  1. semiont/__init__.py +20 -0
  2. semiont/bus.py +236 -0
  3. semiont/cache.py +345 -0
  4. semiont/cached.py +152 -0
  5. semiont/channel.py +81 -0
  6. semiont/channels.py +1306 -0
  7. semiont/client.py +161 -0
  8. semiont/error_codes.py +232 -0
  9. semiont/errors.py +147 -0
  10. semiont/event_bus.py +108 -0
  11. semiont/events.py +101 -0
  12. semiont/http/__init__.py +24 -0
  13. semiont/http/agent.py +232 -0
  14. semiont/http/content.py +234 -0
  15. semiont/http/exchange.py +200 -0
  16. semiont/http/oauth.py +346 -0
  17. semiont/http/session.py +191 -0
  18. semiont/http/sse.py +71 -0
  19. semiont/http/stream.py +683 -0
  20. semiont/http/transport.py +326 -0
  21. semiont/identifiers.py +114 -0
  22. semiont/identity.py +59 -0
  23. semiont/media_types.py +69 -0
  24. semiont/media_types_table.py +89 -0
  25. semiont/model.py +33 -0
  26. semiont/namespaces/__init__.py +19 -0
  27. semiont/namespaces/auth.py +32 -0
  28. semiont/namespaces/beckon.py +47 -0
  29. semiont/namespaces/bind.py +38 -0
  30. semiont/namespaces/browse.py +338 -0
  31. semiont/namespaces/follow.py +238 -0
  32. semiont/namespaces/frame.py +35 -0
  33. semiont/namespaces/gather.py +82 -0
  34. semiont/namespaces/job.py +94 -0
  35. semiont/namespaces/links.py +66 -0
  36. semiont/namespaces/live.py +301 -0
  37. semiont/namespaces/mark.py +174 -0
  38. semiont/namespaces/match.py +67 -0
  39. semiont/namespaces/refresher.py +283 -0
  40. semiont/namespaces/system.py +24 -0
  41. semiont/namespaces/yield_.py +101 -0
  42. semiont/oauth_clients.py +27 -0
  43. semiont/operations.py +337 -0
  44. semiont/py.typed +0 -0
  45. semiont/refresh.py +188 -0
  46. semiont/resume.py +95 -0
  47. semiont/retry.py +164 -0
  48. semiont/running.py +110 -0
  49. semiont/session.py +493 -0
  50. semiont/sign_in.py +54 -0
  51. semiont/sign_in_store.py +283 -0
  52. semiont/storage.py +139 -0
  53. semiont/telemetry.py +116 -0
  54. semiont/telemetry_table.py +121 -0
  55. semiont/testing/__init__.py +62 -0
  56. semiont/testing/client.py +53 -0
  57. semiont/testing/content.py +124 -0
  58. semiont/testing/gateway.py +85 -0
  59. semiont/testing/transport.py +341 -0
  60. semiont/timing.py +187 -0
  61. semiont/transport.py +531 -0
  62. semiont/types.py +5902 -0
  63. semiont/watched.py +111 -0
  64. semiont-0.6.12.dev2.dist-info/METADATA +533 -0
  65. semiont-0.6.12.dev2.dist-info/RECORD +66 -0
  66. semiont-0.6.12.dev2.dist-info/WHEEL +4 -0
semiont/model.py ADDED
@@ -0,0 +1,33 @@
1
+ """The base of every shape the protocol states, and how one is written to the wire."""
2
+
3
+ from pydantic import BaseModel, JsonValue
4
+
5
+ __all__ = ["WireModel", "written"]
6
+
7
+
8
+ class WireModel(BaseModel, frozen=True, validate_by_name=True, validate_by_alias=True, serialize_by_alias=True):
9
+ """A shape of the protocol: immutable, and named two ways.
10
+
11
+ A field has the name Python gives it (`annotation_id`) and the name the wire
12
+ gives it (`annotationId`). A model is built by the first, decoded from
13
+ either, and written out by the second.
14
+
15
+ Every subclass says `frozen=True` for itself: a type checker reads it from
16
+ the class's own line, and refuses one that leaves it out. A class's
17
+ configuration is its class keywords, here and in every subclass.
18
+ """
19
+
20
+
21
+ def written(model: WireModel) -> dict[str, JsonValue]:
22
+ """`model` as the wire carries it: by the wire's names, and without what was never said.
23
+
24
+ A field of the model itself that may be left out, and holds nothing, is
25
+ left out: an option given as `None` is an option not given. What the
26
+ model holds deeper is written as it was given, so a shape read from the
27
+ knowledge base is sent back as it came.
28
+ """
29
+ wire: dict[str, JsonValue] = model.model_dump(mode="json", exclude_unset=True)
30
+ for name, field in type(model).model_fields.items():
31
+ if not field.is_required() and getattr(model, name) is None:
32
+ wire.pop(field.alias or name, None)
33
+ return wire
@@ -0,0 +1,19 @@
1
+ """The namespaces of a client (`semiont.client.SemiontClient`).
2
+
3
+ One per flow of the protocol and three beside them: `frame`, `browse`, `mark`,
4
+ `bind`, `gather`, `match`, `yield_` and `beckon`; and `job`, `auth` and
5
+ `system`.
6
+
7
+ Every method returns one of seven shapes, and its name, its shape and what
8
+ calling it does first are a row of `specs/src/client/surface.json`, which
9
+ every SDK is held to:
10
+
11
+ - an `async def`: asked once, answered once;
12
+ - a `Running`: a long-running operation, its reports and its final value;
13
+ - an `Upload`: an upload's progress and the resource it created;
14
+ - a `Cached`: a query, sent when its `fresh` is called;
15
+ - a plain `def` that returns nothing: a signal, fire-and-forget;
16
+ - an `async def` giving `int | None`: a drive at the other participants, and
17
+ how many the gateway reached;
18
+ - a `Typed`: one channel's events, from now on.
19
+ """
@@ -0,0 +1,32 @@
1
+ """Auth: the gateway's view of who is signed in.
2
+
3
+ Signing in happens at the issuer. This only asks the gateway what it sees.
4
+ """
5
+
6
+ from typing import Final, final
7
+
8
+ from semiont.identifiers import ResourceId
9
+ from semiont.transport import GatewayOperations
10
+ from semiont.types import MediaTokenResponse, ProtectedResourceMetadata, UserResponse
11
+
12
+ __all__ = ["AuthNamespace"]
13
+
14
+
15
+ @final
16
+ class AuthNamespace:
17
+ """See the module's documentation."""
18
+
19
+ def __init__(self, gateway: GatewayOperations) -> None:
20
+ self._gateway: Final = gateway
21
+
22
+ async def me(self) -> UserResponse:
23
+ """The signed-in principal."""
24
+ return await self._gateway.get_current_user()
25
+
26
+ async def media_token(self, resource_id: ResourceId) -> MediaTokenResponse:
27
+ """A token that lets a browser fetch one resource's bytes."""
28
+ return await self._gateway.get_media_token(resource_id)
29
+
30
+ async def protected_resource_metadata(self) -> ProtectedResourceMetadata:
31
+ """Which issuer the knowledge base trusts: where to send someone to sign in."""
32
+ return await self._gateway.get_protected_resource_metadata()
@@ -0,0 +1,47 @@
1
+ """Beckon: attention.
2
+
3
+ The drives go over the wire, to every other participant, and resolve with how
4
+ many the gateway reached, or with no count when it kept none. The signals are
5
+ this viewer's own and never leave the client.
6
+ """
7
+
8
+ from typing import Final, final
9
+
10
+ from semiont.channels import BECKON_FOCUS, BECKON_HOVER, BECKON_SPARKLE, BROWSE_CLICK, BROWSE_RESOURCE_OPEN
11
+ from semiont.identifiers import AnnotationId, ResourceId
12
+ from semiont.namespaces.links import Links
13
+ from semiont.types import BeckonFocusEvent, BeckonHoverEvent, BeckonSparkleEvent, BrowseClickEvent, BrowseResourceOpenEvent
14
+
15
+ __all__ = ["BeckonNamespace"]
16
+
17
+
18
+ @final
19
+ class BeckonNamespace:
20
+ """See the module's documentation."""
21
+
22
+ def __init__(self, links: Links) -> None:
23
+ self._links: Final = links
24
+
25
+ async def attention(self, resource_id: ResourceId, annotation_id: AnnotationId) -> int | None:
26
+ """Point the other participants at an annotation."""
27
+ return await self._links.drive(BECKON_FOCUS, BeckonFocusEvent(annotation_id=annotation_id, resource_id=resource_id))
28
+
29
+ async def click(self, annotation_id: AnnotationId) -> int | None:
30
+ """Open an annotation on the other participants' screens."""
31
+ return await self._links.drive(BROWSE_CLICK, BrowseClickEvent(annotation_id=annotation_id))
32
+
33
+ async def open_resource(self, resource_id: ResourceId) -> int | None:
34
+ """Open a resource on the other participants' screens."""
35
+ return await self._links.drive(BROWSE_RESOURCE_OPEN, BrowseResourceOpenEvent(resource_id=resource_id))
36
+
37
+ async def sparkle_all(self, annotation_id: AnnotationId) -> int | None:
38
+ """Sparkle an annotation on every participant's viewer."""
39
+ return await self._links.drive(BECKON_SPARKLE, BeckonSparkleEvent(annotation_id=annotation_id))
40
+
41
+ def hover(self, annotation_id: AnnotationId | None) -> None:
42
+ """Signal: this viewer hovers an annotation, or none."""
43
+ self._links.signal(BECKON_HOVER, BeckonHoverEvent(annotation_id=annotation_id))
44
+
45
+ def sparkle(self, annotation_id: AnnotationId) -> None:
46
+ """Signal: sparkle an annotation on this viewer alone."""
47
+ self._links.signal(BECKON_SPARKLE, BeckonSparkleEvent(annotation_id=annotation_id))
@@ -0,0 +1,38 @@
1
+ """Bind: linking a reference to what it refers to."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Final, final
5
+
6
+ from semiont.channels import BIND_BODY_ERROR, BIND_INITIATE
7
+ from semiont.identifiers import AnnotationId, ResourceId
8
+ from semiont.namespaces.links import Links
9
+ from semiont.operations import BIND_UPDATE_BODY
10
+ from semiont.types import BindBodyOperation, BindInitiateCommand, BindUpdateBodyCommand, ResourceErrorEvent
11
+
12
+ __all__ = ["BindNamespace"]
13
+
14
+
15
+ @final
16
+ class BindNamespace:
17
+ """See the module's documentation."""
18
+
19
+ def __init__(self, links: Links) -> None:
20
+ self._links: Final = links
21
+
22
+ async def body(self, resource_id: ResourceId, annotation_id: AnnotationId, operations: Sequence[BindBodyOperation]) -> None:
23
+ """Change an annotation's body.
24
+
25
+ Confirmed: it resolves when the change is recorded, and fails with the
26
+ failure the knowledge base answered.
27
+ """
28
+ await self._links.request(
29
+ BIND_UPDATE_BODY, BindUpdateBodyCommand(annotation_id=annotation_id, resource_id=resource_id, operations=list(operations))
30
+ )
31
+
32
+ def initiate(self, command: BindInitiateCommand) -> None:
33
+ """Signal: a binding is wanted for an annotation."""
34
+ self._links.signal(BIND_INITIATE, command)
35
+
36
+ def report_body_error(self, event: ResourceErrorEvent) -> None:
37
+ """Signal: a body update failed where nothing could show it."""
38
+ self._links.signal(BIND_BODY_ERROR, event)
@@ -0,0 +1,338 @@
1
+ """Browse: reads, and this viewer's own signals.
2
+
3
+ The queries (`Cached`) answer from the client's cache: one read with `fresh`,
4
+ or held with `async with` and watched. The one-shot reads are asked once. The
5
+ signals are this viewer's own, with the one report among them going over the
6
+ wire.
7
+ """
8
+
9
+ import asyncio
10
+ import codecs
11
+ from collections.abc import AsyncIterator, Callable, Hashable
12
+ from dataclasses import dataclass
13
+ from typing import Final, Self, final, override
14
+
15
+ from semiont.cache import Cache, CacheState, Ready
16
+ from semiont.cached import Cached, Keyed, Source, itself, shown
17
+ from semiont.channels import BROWSE_CLICK, BROWSE_RESOURCE_OPEN, BROWSE_RESOURCE_VIEWED
18
+ from semiont.errors import SemiontError, TransportError
19
+ from semiont.identifiers import AnnotationId, ResourceId
20
+ from semiont.namespaces.links import Links
21
+ from semiont.namespaces.live import LIMITS_HOLDERS, WHOLE, Live, ResourceFilters
22
+ from semiont.operations import (
23
+ BROWSE_ANCHORED_TEXT_REQUESTED,
24
+ BROWSE_ANNOTATION_HISTORY_REQUESTED,
25
+ BROWSE_DIRECTORY_REQUESTED,
26
+ BROWSE_KB_REQUESTED,
27
+ )
28
+ from semiont.transport import Content, ContentStream, ContentTransport, ResourceHold
29
+ from semiont.types import (
30
+ AgentSoftware,
31
+ AnchoredTextAnswer,
32
+ Annotation,
33
+ AttributedEvent,
34
+ BrowseAnchoredTextRequest,
35
+ BrowseAnnotationHistoryRequest,
36
+ BrowseClickEvent,
37
+ BrowseDirectoryRequest,
38
+ BrowseDirectoryRequestSort,
39
+ BrowseDirectoryResultResponse,
40
+ BrowseKbRequest,
41
+ BrowseResourceOpenEvent,
42
+ BrowseResourceViewedEvent,
43
+ CollaboratorEntry,
44
+ GetAnnotationHistoryResponse,
45
+ GetAnnotationsResponse,
46
+ GetResourceResponse,
47
+ InferenceLimits,
48
+ InferencePairLimits,
49
+ KbDescription,
50
+ ListResourcesResponse,
51
+ ResourceDescriptor,
52
+ TagSchema,
53
+ )
54
+
55
+ __all__ = ["BrowseNamespace", "Collaborator"]
56
+
57
+ # How many resources a list asks for when its caller states no limit. Every
58
+ # SDK asks for as many: the cases of specs/src/client/surface.json hold each to it.
59
+ _LIST_LIMIT: Final = 100
60
+
61
+ # What a browser reads these labels as: the Encoding Standard gives them to
62
+ # windows-1252, of which each is a part.
63
+ _READ_AS_WINDOWS_1252: Final = frozenset({"iso-8859-1", "iso8859-1", "latin1", "l1", "ascii", "us-ascii"})
64
+
65
+
66
+ @final
67
+ @dataclass(frozen=True, slots=True, kw_only=True)
68
+ class Collaborator:
69
+ """One of the knowledge base's collaborators.
70
+
71
+ Its entry in the directory, and its model's limits when the service
72
+ holding that model's credentials reported them.
73
+ """
74
+
75
+ entry: CollaboratorEntry
76
+ limits: InferenceLimits | None = None
77
+
78
+
79
+ def _joined(directory: list[CollaboratorEntry], reported: list[InferencePairLimits]) -> list[Collaborator]:
80
+ """The directory with each reported model's limits on its entries."""
81
+ by_model = {(pair.provider, pair.model): pair.limits for pair in reversed(reported)}
82
+ return [
83
+ Collaborator(
84
+ entry=entry,
85
+ limits=by_model.get((entry.agent.provider, entry.agent.model))
86
+ if isinstance(entry.agent, AgentSoftware) and entry.agent.provider is not None and entry.agent.model is not None
87
+ else None,
88
+ )
89
+ for entry in directory
90
+ ]
91
+
92
+
93
+ def _listed(answer: GetAnnotationsResponse) -> list[Annotation]:
94
+ return answer.annotations
95
+
96
+
97
+ async def _next[S](states: AsyncIterator[S]) -> S | None:
98
+ """The next state, or nothing when there will be no other."""
99
+ return await anext(states, None)
100
+
101
+
102
+ @final
103
+ class _Joined:
104
+ """The directory's state, with what the key holders have reported so far.
105
+
106
+ A holder that has not answered delays only its own models' limits.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ directory: AsyncIterator[CacheState[list[CollaboratorEntry]]],
112
+ reports: list[AsyncIterator[CacheState[list[InferencePairLimits]]]],
113
+ ) -> None:
114
+ self._directory: Final = directory
115
+ self._reports: Final = reports
116
+ self._entries: CacheState[list[CollaboratorEntry]] | None = None
117
+ self._reported: Final[list[list[InferencePairLimits]]] = [[] for _ in reports]
118
+ self._shown: CacheState[list[Collaborator]] | None = None
119
+ """The state last given: a report that changes nothing a watcher is shown is not a state."""
120
+
121
+ def __aiter__(self) -> Self:
122
+ return self
123
+
124
+ async def __anext__(self) -> CacheState[list[Collaborator]]:
125
+ while True:
126
+ directory = asyncio.ensure_future(_next(self._directory))
127
+ reports = [asyncio.ensure_future(_next(report)) for report in self._reports]
128
+ waits: list[asyncio.Task[object]] = [directory, *reports]
129
+ try:
130
+ await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
131
+ finally:
132
+ # One that has not answered has taken nothing: it is asked again the next time.
133
+ for wait in waits:
134
+ wait.cancel()
135
+ changed = False
136
+ if directory.done():
137
+ entries = directory.result()
138
+ if entries is None:
139
+ raise StopAsyncIteration
140
+ self._entries, changed = entries, True
141
+ for index, report in enumerate(reports):
142
+ if report.done():
143
+ state = report.result()
144
+ if state is None:
145
+ # The caches end together, when their client closes.
146
+ raise StopAsyncIteration
147
+ # A holder's report is pending, then what it reported: it never fails (`Live`).
148
+ if isinstance(state, Ready):
149
+ self._reported[index], changed = state.value, True
150
+ if self._entries is not None and changed:
151
+ joined = shown(self._entries, self._with_limits)
152
+ if joined != self._shown:
153
+ self._shown = joined
154
+ return joined
155
+
156
+ def _with_limits(self, entries: list[CollaboratorEntry]) -> list[Collaborator]:
157
+ return _joined(entries, [pair for report in self._reported for pair in report])
158
+
159
+
160
+ @final
161
+ class _Collaborators(Source[list[Collaborator]]):
162
+ """The collaborator directory with each key holder's limits joined on."""
163
+
164
+ def __init__(self, live: Live) -> None:
165
+ self._live: Final = live
166
+
167
+ @override
168
+ async def fresh(self) -> list[Collaborator]:
169
+ try:
170
+ async with asyncio.TaskGroup() as asking:
171
+ # The directory is asked for first; each key holder's limits after it.
172
+ directory = asking.create_task(self._live.agents.fetch(WHOLE))
173
+ reports = [asking.create_task(self._live.limits.fetch(holder)) for holder in LIMITS_HOLDERS]
174
+ except* SemiontError as failed:
175
+ # The directory's failure, or the client's closing: a holder that fails reports none, and fails nothing.
176
+ raise failed.exceptions[0] from None
177
+ return _joined(directory.result(), [pair for report in reports for pair in report.result()])
178
+
179
+ @override
180
+ def watch(self) -> AsyncIterator[CacheState[list[Collaborator]]]:
181
+ directory = self._live.agents.observe(WHOLE)
182
+ return _Joined(directory, [self._live.limits.observe(holder) for holder in LIMITS_HOLDERS])
183
+
184
+ @override
185
+ def invalidate(self) -> None:
186
+ self._live.invalidate_agents()
187
+
188
+ @override
189
+ def hold(self) -> ResourceHold | None:
190
+ return None
191
+
192
+
193
+ def _charset(media_type: str) -> str | None:
194
+ """The charset a media type states, in lower case."""
195
+ lower = media_type.lower()
196
+ _, found, stated = lower.partition("charset=")
197
+ if found == "":
198
+ return None
199
+ for index, character in enumerate(stated):
200
+ if character.isspace() or character == ";":
201
+ return stated[:index]
202
+ return stated
203
+
204
+
205
+ def _text(content: Content) -> str:
206
+ """Bytes as the text their media type says they are.
207
+
208
+ In the encoding the charset names, UTF-8 when none is stated; a leading
209
+ byte-order mark is not part of the text; and a sequence the encoding does
210
+ not have becomes U+FFFD. A charset there is no decoder for is refused by
211
+ name rather than read as something it is not: the bytes are there to
212
+ decode (`resource_representation`).
213
+ """
214
+ label = (_charset(content.content_type) or "utf-8").strip("\"'")
215
+ try:
216
+ decoder = codecs.lookup("windows-1252" if label in _READ_AS_WINDOWS_1252 else label)
217
+ except LookupError:
218
+ raise TransportError(
219
+ "error", f"The resource's text is {label}, which this build decodes no text from: read its bytes instead"
220
+ ) from None
221
+ return decoder.decode(content.data, "replace")[0].removeprefix("\ufeff")
222
+
223
+
224
+ @final
225
+ class BrowseNamespace:
226
+ """See the module's documentation."""
227
+
228
+ def __init__(self, links: Links, content: ContentTransport, live: Live) -> None:
229
+ self._links: Final = links
230
+ self._content: Final = content
231
+ self._live: Final = live
232
+
233
+ # ── Queries ─────────────────────────────────────────────────────────
234
+
235
+ def _of[K: Hashable, V, T](self, resource_id: ResourceId, cache: Cache[K, V], key: K, view: Callable[[V], T]) -> Cached[T]:
236
+ """A query of one resource: watching it holds the resource's scope."""
237
+ return self._live.query(Keyed(cache, key, view, scope=(self._links.wire.transport, resource_id)))
238
+
239
+ def resource(self, resource_id: ResourceId) -> Cached[ResourceDescriptor]:
240
+ """A resource's description."""
241
+ return self._of(resource_id, self._live.resource, resource_id, itself)
242
+
243
+ def resources(
244
+ self, *, limit: int = _LIST_LIMIT, archived: bool | None = None, entity_type: str | None = None
245
+ ) -> Cached[ListResourcesResponse]:
246
+ """A page of the resources the filters admit: the first hundred when no limit is stated.
247
+
248
+ It is kept true by what every client hears and by the events of the
249
+ resources this client holds the scope of, and by nothing else.
250
+ Finding resources by text is `match.resources`.
251
+ """
252
+ return self._live.query(Keyed(self._live.lists, ResourceFilters(limit, archived, entity_type), itself))
253
+
254
+ def annotations(self, resource_id: ResourceId) -> Cached[list[Annotation]]:
255
+ """A resource's annotations."""
256
+ return self._of(resource_id, self._live.annotations, resource_id, _listed)
257
+
258
+ def annotation(self, resource_id: ResourceId, annotation_id: AnnotationId) -> Cached[Annotation]:
259
+ """One annotation of a resource."""
260
+ self._live.annotation_of[annotation_id] = resource_id
261
+ return self._of(resource_id, self._live.annotation, annotation_id, itself)
262
+
263
+ def entity_types(self) -> Cached[list[str]]:
264
+ """The knowledge base's entity types."""
265
+ return self._live.query(Keyed(self._live.entity_types, WHOLE, itself))
266
+
267
+ def tag_schemas(self) -> Cached[list[TagSchema]]:
268
+ """The knowledge base's tag schemas."""
269
+ return self._live.query(Keyed(self._live.tag_schemas, WHOLE, itself))
270
+
271
+ def agents(self) -> Cached[list[Collaborator]]:
272
+ """The knowledge base's collaborators.
273
+
274
+ The directory, asked for first, with each model's limits as the
275
+ services holding its credentials report them.
276
+ """
277
+ return self._live.query(_Collaborators(self._live))
278
+
279
+ def events(self, resource_id: ResourceId) -> Cached[list[AttributedEvent]]:
280
+ """A resource's events, each with who it is attributed to."""
281
+ return self._of(resource_id, self._live.events, resource_id, itself)
282
+
283
+ # ── One-shot reads ──────────────────────────────────────────────────
284
+
285
+ async def resource_content(self, resource_id: ResourceId) -> str:
286
+ """A resource's bytes as text, in the charset their media type states: UTF-8 when it states none."""
287
+ return _text(await self._content.get_binary(resource_id))
288
+
289
+ async def resource_graph(self, resource_id: ResourceId) -> GetResourceResponse:
290
+ """A resource's description as linked data: itself, its annotations and the references to it."""
291
+ return await self._content.get_resource_graph(resource_id)
292
+
293
+ async def resource_anchored_text(self, resource_id: ResourceId) -> AnchoredTextAnswer:
294
+ """A resource's recovered text and the runs that index it, or the named reason there is none."""
295
+ answer = await self._links.request(BROWSE_ANCHORED_TEXT_REQUESTED, BrowseAnchoredTextRequest(resource_id=resource_id))
296
+ return answer.response
297
+
298
+ async def resource_representation(self, resource_id: ResourceId) -> Content:
299
+ """A resource's bytes, unchanged, with their media type."""
300
+ return await self._content.get_binary(resource_id)
301
+
302
+ async def resource_representation_stream(self, resource_id: ResourceId) -> ContentStream:
303
+ """The same, as a stream."""
304
+ return await self._content.get_binary_stream(resource_id)
305
+
306
+ async def resource_events(self, resource_id: ResourceId) -> list[AttributedEvent]:
307
+ """A resource's events, each with who it is attributed to."""
308
+ return await self._live.events_of(resource_id)
309
+
310
+ async def annotation_history(self, resource_id: ResourceId, annotation_id: AnnotationId) -> GetAnnotationHistoryResponse:
311
+ """The events of one annotation."""
312
+ request = BrowseAnnotationHistoryRequest(resource_id=resource_id, annotation_id=annotation_id)
313
+ return (await self._links.request(BROWSE_ANNOTATION_HISTORY_REQUESTED, request)).response
314
+
315
+ async def files(self, path: str = ".", *, sort: BrowseDirectoryRequestSort = "name") -> BrowseDirectoryResultResponse:
316
+ """The entries of a directory of the knowledge base's tree: its root when no path is stated, by name when no order is."""
317
+ return (await self._links.request(BROWSE_DIRECTORY_REQUESTED, BrowseDirectoryRequest(path=path, sort=sort))).response
318
+
319
+ async def kb(self) -> KbDescription:
320
+ """What the knowledge base says of itself: its name and domain, and its working tree's branch.
321
+
322
+ Asked every time: a branch changes with no event to say so.
323
+ """
324
+ return (await self._links.request(BROWSE_KB_REQUESTED, BrowseKbRequest())).response
325
+
326
+ # ── Signals ─────────────────────────────────────────────────────────
327
+
328
+ def click(self, annotation_id: AnnotationId) -> None:
329
+ """Signal: open an annotation for this viewer."""
330
+ self._links.signal(BROWSE_CLICK, BrowseClickEvent(annotation_id=annotation_id))
331
+
332
+ def open_resource(self, resource_id: ResourceId) -> None:
333
+ """Signal: open a resource for this viewer."""
334
+ self._links.signal(BROWSE_RESOURCE_OPEN, BrowseResourceOpenEvent(resource_id=resource_id))
335
+
336
+ def resource_viewed(self, resource_id: ResourceId) -> None:
337
+ """Report that this viewer is looking at a resource. It goes over the wire, and nobody awaits it."""
338
+ self._links.report(BROWSE_RESOURCE_VIEWED, BrowseResourceViewedEvent(resource_id=resource_id))