soothe-client-python 1.0.21__tar.gz → 1.0.23__tar.gz

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 (34) hide show
  1. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/PKG-INFO +1 -1
  2. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/__init__.py +0 -3
  3. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/__init__.py +3 -11
  4. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/attachments.py +6 -6
  5. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/broadcaster.py +3 -3
  6. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/card_projection.py +1 -1
  7. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/chunk_filter.py +3 -3
  8. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/classifier.py +7 -8
  9. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/daemon_session.py +38 -57
  10. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/events.py +9 -16
  11. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/loop_session_store.py +2 -2
  12. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/managed_client.py +6 -6
  13. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/observability.py +1 -1
  14. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/pool.py +5 -9
  15. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/query_gate.py +3 -3
  16. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/thinking_step.py +2 -3
  17. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/turn.py +3 -8
  18. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/turn_boundary.py +1 -1
  19. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/turn_runner.py +5 -5
  20. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/command_client.py +27 -45
  21. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/errors.py +1 -1
  22. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/helpers.py +49 -62
  23. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/intent_hints.py +1 -1
  24. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/protocol_params.py +52 -252
  25. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/schemas.py +1 -4
  26. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/session.py +27 -31
  27. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/stream_terminal.py +7 -7
  28. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/turn_boundary.py +17 -17
  29. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/websocket.py +117 -224
  30. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/.gitignore +0 -0
  31. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/LICENSE +0 -0
  32. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/README.md +0 -0
  33. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/pyproject.toml +0 -0
  34. {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: soothe-client-python
3
- Version: 1.0.21
3
+ Version: 1.0.23
4
4
  Summary: WebSocket client + appkit for soothe-daemon (Python)
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe-client-python
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -5,9 +5,6 @@ Public surface::
5
5
  from soothe_client import WebSocketClient, is_daemon_live
6
6
  from soothe_client import AsyncCommandClient, CommandClient
7
7
  from soothe_client.appkit import DaemonSession
8
-
9
- Wire request param models: ``soothe_client.protocol_params``.
10
- Session / multi-user helpers: ``soothe_client.appkit``.
11
8
  """
12
9
 
13
10
  from __future__ import annotations
@@ -1,16 +1,8 @@
1
1
  """Application helpers built on WebSocketClient.
2
2
 
3
- Primary building blocks for agent UIs and backends:
4
-
5
- - ``DaemonSession`` — dual-socket loop session + streamed turns
6
- - ``ConnectionPool`` / ``TurnRunner`` — multi-session turn execution
7
- - ``QueryGate`` — one-in-flight query per session
8
- - ``EventClassifier`` — stream → deliverable mapping
9
- - ``SSEBroadcaster`` — drop-on-full fan-out to subscribers
10
- - ``LoopSessionStore`` — persistence seam (Protocol)
11
-
12
- Advanced stream/pipeline plumbing lives in submodules
13
- (``events``, ``chunk_filter``, ``turn``, ``managed_client``).
3
+ Building blocks for agent UIs and backends: `DaemonSession`,
4
+ `ConnectionPool` / `TurnRunner`, `QueryGate`, `EventClassifier`,
5
+ `SSEBroadcaster`, `LoopSessionStore`.
14
6
  """
15
7
 
16
8
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """Image attachment compaction for appkit.
2
2
 
3
- Downscales ``image/*`` payloads when either dimension exceeds a max size.
3
+ Downscales `image/*` payloads when either dimension exceeds a max size.
4
4
  Non-images and decode failures pass through unchanged.
5
5
  """
6
6
 
@@ -16,7 +16,7 @@ from PIL import Image
16
16
 
17
17
  @dataclass(slots=True)
18
18
  class CompactImageOptions:
19
- """Controls ``compact_image_attachment``. Zero / unset fields use defaults."""
19
+ """Controls `compact_image_attachment`. Zero / unset fields use defaults."""
20
20
 
21
21
  max_dim: int = 768
22
22
  jpeg_quality: int = 85
@@ -38,15 +38,15 @@ def compact_image_attachment(
38
38
  data_b64: str,
39
39
  opts: CompactImageOptions | None = None,
40
40
  ) -> tuple[str, str]:
41
- """Downscale an image attachment when either dimension exceeds ``max_dim``.
41
+ """Downscale an image attachment when either dimension exceeds `max_dim`.
42
42
 
43
43
  Args:
44
- mime_type: Attachment MIME type (e.g. ``image/png``).
44
+ mime_type: Attachment MIME type (e.g. `image/png`).
45
45
  data_b64: Base64-encoded image bytes.
46
46
  opts: Optional size / JPEG quality overrides.
47
47
 
48
48
  Returns:
49
- ``(out_mime, out_b64)``. Non-images and failures return inputs unchanged.
49
+ `(out_mime, out_b64)`. Non-images and failures return inputs unchanged.
50
50
  """
51
51
  if not data_b64 or not mime_type.startswith("image/"):
52
52
  return mime_type, data_b64
@@ -92,7 +92,7 @@ def compact_attachments(
92
92
  atts: list[dict[str, Any]] | None,
93
93
  opts: CompactImageOptions | None = None,
94
94
  ) -> list[dict[str, Any]]:
95
- """Apply ``compact_image_attachment`` to each ``mime_type`` + ``data`` map."""
95
+ """Apply `compact_image_attachment` to each `mime_type` + `data` map."""
96
96
  if not atts:
97
97
  return atts or []
98
98
  out: list[dict[str, Any]] = []
@@ -19,7 +19,7 @@ SUBSCRIBER_QUEUE_CAP = 100
19
19
 
20
20
  @dataclass(frozen=True, slots=True)
21
21
  class SSEEvent:
22
- """One Server-Sent Event payload. The ``type`` vocabulary is app-defined."""
22
+ """One Server-Sent Event payload. The `type` vocabulary is app-defined."""
23
23
 
24
24
  type: str
25
25
  data: Any = None
@@ -40,9 +40,9 @@ class SSEBroadcaster:
40
40
  self._subscribers: dict[str, dict[str, _Subscriber]] = {}
41
41
 
42
42
  def subscribe(self, session_id: str) -> tuple[AsyncIterator[SSEEvent], str]:
43
- """Register a subscriber and return ``(async iterator, subscription id)``.
43
+ """Register a subscriber and return `(async iterator, subscription id)`.
44
44
 
45
- Unsubscribe via ``unsubscribe`` or ``close``.
45
+ Unsubscribe via `unsubscribe` or `close`.
46
46
  """
47
47
  sub_id = str(uuid4())
48
48
  sub = _Subscriber()
@@ -1,4 +1,4 @@
1
- """In-memory apply helpers for daemon ``soothe.card.*`` frames."""
1
+ """In-memory apply helpers for daemon `soothe.card.*` frames."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -1,7 +1,7 @@
1
1
  """Early filters for daemon stream chunks (wire / dict-shaped payloads).
2
2
 
3
3
  CLI callers that also see LangChain message objects should pass a richer filter
4
- callback (e.g. ``soothe_cli.runtime.wire.chunk_filter.should_drop_stream_chunk_early``).
4
+ callback (e.g. `soothe_cli.runtime.wire.chunk_filter.should_drop_stream_chunk_early`).
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -12,7 +12,7 @@ _MSG_PAIR_LEN = 2
12
12
 
13
13
 
14
14
  def updates_chunk_is_noop(data: Any) -> bool:
15
- """True when an ``updates`` chunk carries no LangGraph interrupt."""
15
+ """True when an `updates` chunk carries no LangGraph interrupt."""
16
16
  if not isinstance(data, dict):
17
17
  return True
18
18
  return "__interrupt__" not in data
@@ -62,7 +62,7 @@ def _plain_text(msg: dict[str, Any]) -> str:
62
62
 
63
63
 
64
64
  def message_chunk_is_non_actionable(data: Any) -> bool:
65
- """True when a wire ``messages`` pair has no tool, text, or loop phase payload."""
65
+ """True when a wire `messages` pair has no tool, text, or loop phase payload."""
66
66
  if not isinstance(data, (list, tuple)) or len(data) != _MSG_PAIR_LEN:
67
67
  return False
68
68
  msg = data[0]
@@ -2,7 +2,7 @@
2
2
 
3
3
  Maps a stream of decoded daemon events into deliverable/streaming/terminal
4
4
  outcomes, keyed on (namespace, mode, phase). Product apps pass their own
5
- ``deliverable_phases`` set; appkit stays product-agnostic.
5
+ `deliverable_phases` set; appkit stays product-agnostic.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -40,18 +40,17 @@ class ChatEventResult:
40
40
 
41
41
  @dataclass(frozen=True, slots=True)
42
42
  class ClassifierConfig:
43
- """Product-specific decisions an ``EventClassifier`` needs.
43
+ """Product-specific decisions an `EventClassifier` needs.
44
44
 
45
45
  Attributes:
46
- deliverable_phases: Message ``phase`` values that may end a query with
46
+ deliverable_phases: Message `phase` values that may end a query with
47
47
  user-facing text. Required.
48
48
  min_deliverable_runes: Minimum trimmed rune count for a reply to be
49
- persisted as final (avoids finishing on stub ACKs). Defaults to 8.
49
+ persisted as final. Defaults to 8.
50
50
  thinking_step_events: Optional override of the default thinking-step
51
51
  event allowlist.
52
- treat_status_idle_as_complete: Standalone ``classify`` only. Prefer
53
- ``TurnRunner`` + ``TurnBoundary`` for turn end (DaemonSession
54
- contract). Default false.
52
+ treat_status_idle_as_complete: Standalone `classify` only. Prefer
53
+ `TurnRunner` + `TurnBoundary` for turn end. Default false.
55
54
  """
56
55
 
57
56
  deliverable_phases: frozenset[str] | set[str]
@@ -100,7 +99,7 @@ class EventClassifier:
100
99
  return "soothe.output" in event_type and "responded" in event_type
101
100
 
102
101
  def is_deliverable_loop_phase(self, phase: str) -> bool:
103
- """Return whether ``phase`` is in the configured deliverable set."""
102
+ """Return whether `phase` is in the configured deliverable set."""
104
103
  return phase in self._deliverable_phases
105
104
 
106
105
  def is_substantive_assistant_reply(self, content: str) -> bool:
@@ -1,7 +1,7 @@
1
1
  """Dual-socket daemon loop session with turn streaming.
2
2
 
3
3
  Owns a subscribed stream WebSocket plus an RPC sidecar so metadata calls do not
4
- starve loop events. ``iter_turn_chunks`` handles idle timeout, post-idle drain,
4
+ starve loop events. `iter_turn_chunks` handles idle timeout, post-idle drain,
5
5
  loop scoping, and connection-loss detection.
6
6
  """
7
7
 
@@ -57,9 +57,6 @@ class DaemonSession:
57
57
  async for _ns, mode, data in session.iter_turn_chunks():
58
58
  ...
59
59
  await session.close()
60
-
61
- Advanced: ``early_drop_fn`` / ``stats_factory`` customize stream filtering
62
- and metrics for product UIs; most apps leave the defaults.
63
60
  """
64
61
 
65
62
  def __init__(
@@ -103,12 +100,7 @@ class DaemonSession:
103
100
  self._drain_saw_successor_running: bool = False
104
101
 
105
102
  def _reset_turn_seq_floor(self) -> None:
106
- """Clear seq/turn filters when the peer seq namespace may have restarted.
107
-
108
- Keep-TUI reconnect after a daemon restart (or subscribe to a different
109
- loop) would otherwise drop all low-seq frames — including
110
- ``status=running`` — leaving ``query_started=False`` and a silent TUI.
111
- """
103
+ """Clear seq/turn filters when the peer seq namespace may have restarted."""
112
104
  self._last_turn_end_seq = 0
113
105
  self._last_completed_turn_gen = 0
114
106
  self._expected_turn_id = None
@@ -171,7 +163,7 @@ class DaemonSession:
171
163
  """Open the stream socket and bootstrap (or resume) a loop session.
172
164
 
173
165
  Returns:
174
- Status event from the daemon (includes ``loop_id`` on success).
166
+ Status event from the daemon (includes `loop_id` on success).
175
167
  """
176
168
  await connect_websocket_with_retries(self._client)
177
169
  return await self._bootstrap_loop(resume_loop_id=resume_loop_id)
@@ -201,11 +193,7 @@ class DaemonSession:
201
193
  return await self._bootstrap_loop(resume_loop_id=loop_id)
202
194
 
203
195
  async def ensure_connected(self) -> None:
204
- """Reconnect and re-subscribe when the stream WebSocket died.
205
-
206
- Prefers ``reconnect`` + ``reattach_and_probe`` when a loop id is known;
207
- falls back to bootstrap on ``StaleLoopError``.
208
- """
196
+ """Reconnect and re-subscribe when the stream WebSocket died."""
209
197
  from soothe_client.errors import StaleLoopError
210
198
 
211
199
  is_disconn = getattr(self._client, "is_disconnected", None)
@@ -266,7 +254,7 @@ class DaemonSession:
266
254
  self._rpc_connected = False
267
255
 
268
256
  async def detach(self) -> None:
269
- """Notify the daemon that this client is leaving (``disconnect``)."""
257
+ """Notify the daemon that this client is leaving (`disconnect`)."""
270
258
  if not self._client.is_connected:
271
259
  logger.debug("Skipping detach — connection already closed")
272
260
  return
@@ -289,11 +277,11 @@ class DaemonSession:
289
277
  clarification_answer: bool = False,
290
278
  clarification_answers: list[str] | None = None,
291
279
  intent_hint: str | None = None,
280
+ autopilot_rail_id: str | None = None,
292
281
  ) -> None:
293
282
  """Send a user message on the active loop (start of a turn).
294
283
 
295
- Call ``iter_turn_chunks`` afterward to consume the streamed reply.
296
- Requires ``connect()`` (or another bootstrap) first.
284
+ Call `iter_turn_chunks` afterward to consume the streamed reply.
297
285
  """
298
286
  if not self._loop_id:
299
287
  raise RuntimeError("No active loop session")
@@ -310,10 +298,11 @@ class DaemonSession:
310
298
  clarification_answer=clarification_answer,
311
299
  clarification_answers=clarification_answers,
312
300
  intent_hint=intent_hint,
301
+ autopilot_rail_id=autopilot_rail_id,
313
302
  )
314
303
 
315
304
  async def cancel_remote_query(self) -> None:
316
- """Ask the daemon to cancel via slash ``/cancel`` (CLI wire path)."""
305
+ """Ask the daemon to cancel via slash `/cancel` (CLI wire path)."""
317
306
  await self._client.notify("slash_command", {"cmd": "/cancel"})
318
307
 
319
308
  async def cancel_active_turn(self) -> None:
@@ -325,10 +314,10 @@ class DaemonSession:
325
314
  *,
326
315
  expected_loop_id: str | None,
327
316
  ) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
328
- """Yield stream chunks that arrive just after ``idle``.
317
+ """Yield stream chunks that arrive just after `idle`.
329
318
 
330
- If a successor ``status=running`` (generation above the completed floor)
331
- arrives, re-queue it and set ``_drain_saw_successor_running`` so the
319
+ If a successor `status=running` (generation above the completed floor)
320
+ arrives, re-queue it and set `_drain_saw_successor_running` so the
332
321
  caller can continue the reader instead of ending the turn.
333
322
  """
334
323
  self._drain_saw_successor_running = False
@@ -389,7 +378,7 @@ class DaemonSession:
389
378
  continue
390
379
 
391
380
  async def list_loops(self, *, limit: int = 20) -> dict[str, Any]:
392
- """Return ``loop_list`` via the RPC sidecar."""
381
+ """Return `loop_list` via the RPC sidecar."""
393
382
  async with self._rpc_lock:
394
383
  await self._ensure_rpc_connected()
395
384
  return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
@@ -400,22 +389,15 @@ class DaemonSession:
400
389
  max_wait_s: float | None = None,
401
390
  idle_timeout_s: float | None = None,
402
391
  ) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
403
- """Yield ``(namespace, mode, data)`` stream chunks until the turn ends.
392
+ """Yield `(namespace, mode, data)` stream chunks until the turn ends.
404
393
 
405
394
  Handles idle timeout, post-idle drain, and turn boundary filtering.
406
- Call after ``send_turn``.
395
+ Call after `send_turn`.
407
396
 
408
397
  Args:
409
- max_wait_s: Optional absolute deadline for the whole turn. When set,
410
- raises ``TimeoutError`` if the daemon never emits a turn-end
411
- signal in time.
412
- idle_timeout_s: Optional hard cap on how long to wait for the *first*
413
- progress event before giving up. When set and no progress is seen
414
- within this many seconds, raises ``TimeoutError`` instead of the
415
- default 30s-log-and-reset behavior. Used by attach-only reads
416
- (``skip_daemon_send_turn``) so a stale ``live`` probe that turns
417
- out to have no real follow-on turn does not spin the thinking
418
- row for minutes.
398
+ max_wait_s: Optional absolute deadline for the whole turn.
399
+ idle_timeout_s: Optional cap on how long to wait for the first
400
+ progress event before raising `TimeoutError`.
419
401
  """
420
402
  self._ensure_turn_floor_attrs()
421
403
  self.turn_event_stats = self._new_turn_stats()
@@ -437,7 +419,7 @@ class DaemonSession:
437
419
  first_event_logged = False
438
420
  progress_seen = False
439
421
  # Wall-clock of the last *real* progress (a yielded chunk). A stale
440
- # ``status=running`` frame sets ``progress_seen`` but yields nothing;
422
+ # `status=running` frame sets `progress_seen` but yields nothing;
441
423
  # for the attach-only idle timeout we need a signal that advances when
442
424
  # the daemon actually streams content, not when it merely re-announces
443
425
  # a (possibly stale) running status.
@@ -474,7 +456,7 @@ class DaemonSession:
474
456
  )
475
457
  turn_read_started = time.monotonic()
476
458
  # Attach-only idle timeout: even after a stale
477
- # ``status=running`` sets ``progress_seen``, no real content
459
+ # `status=running` sets `progress_seen`, no real content
478
460
  # may ever arrive (the runner already exited). Bound the wait
479
461
  # for the first yielded chunk so the TUI falls back instead
480
462
  # of spinning the thinking row for minutes.
@@ -735,13 +717,13 @@ class DaemonSession:
735
717
  return [s for s in skills if isinstance(s, dict)]
736
718
 
737
719
  async def list_models(self) -> dict[str, Any]:
738
- """Return daemon ``models_list`` result."""
720
+ """Return daemon `models_list` result."""
739
721
  async with self._rpc_lock:
740
722
  await self._ensure_rpc_connected()
741
723
  return await self._rpc_client.list_models(timeout=15.0)
742
724
 
743
725
  async def get_mcp_status(self) -> dict[str, Any]:
744
- """Return daemon ``mcp_status`` result."""
726
+ """Return daemon `mcp_status` result."""
745
727
  async with self._rpc_lock:
746
728
  await self._ensure_rpc_connected()
747
729
  return await self._rpc_client.get_mcp_status(timeout=15.0)
@@ -764,20 +746,26 @@ class DaemonSession:
764
746
  interaction_mode=interaction_mode,
765
747
  )
766
748
 
767
- async def set_clarification_mode(self, mode: str) -> bool:
768
- """Hot-swap the clarification mode on the running goal (RFC-622).
749
+ async def set_clarification_mode(
750
+ self,
751
+ mode: str,
752
+ *,
753
+ interaction_mode: str | None = None,
754
+ ) -> bool:
755
+ """Hot-swap the agent mode on the running goal.
769
756
 
770
- Sends ``loop_set_clarification_mode`` on the RPC socket. Returns
771
- ``True`` when the swap landed on a live goal; ``False`` when no goal
772
- is currently running (retry on the next turn).
757
+ Returns `True` on a live goal, `False` when none is running.
773
758
  """
774
759
  if not self._loop_id:
775
760
  return False
761
+ params: dict[str, Any] = {"loop_id": self._loop_id, "mode": mode}
762
+ if interaction_mode is not None:
763
+ params["interaction_mode"] = interaction_mode
776
764
  async with self._rpc_lock:
777
765
  await self._ensure_rpc_connected()
778
766
  result = await self._rpc_client.request(
779
767
  "loop_set_clarification_mode",
780
- {"loop_id": self._loop_id, "mode": mode},
768
+ params,
781
769
  timeout=5.0,
782
770
  )
783
771
  return bool(result.get("applied", False))
@@ -872,10 +860,7 @@ class DaemonSession:
872
860
  timeout: float = 10.0,
873
861
  as_node: str | None = None,
874
862
  ) -> dict[str, Any]:
875
- """Merge partial StrangeLoop state via daemon ``loop_state_update``.
876
-
877
- Used by the TUI for interrupt cleanup and token-total persistence.
878
- """
863
+ """Merge partial StrangeLoop state via daemon `loop_state_update`."""
879
864
  lid = str(loop_id or "").strip()
880
865
  if not lid or not isinstance(values, dict):
881
866
  return {}
@@ -896,11 +881,7 @@ class DaemonSession:
896
881
  async def fetch_execution_state(self, loop_id: str) -> SimpleNamespace:
897
882
  """Fetch execution-progress snapshot (plan, step_index, iteration, status).
898
883
 
899
- Returns a namespace with ``plan``, ``step_index``, ``iteration``,
900
- ``status``, and ``active_runner`` for the loop's bound checkpoint
901
- thread. On RPC failure the fields degrade gracefully (``step_index=0``,
902
- ``iteration=0``, ``status=None``, ``plan=None``, ``active_runner=False``)
903
- so a resume gate can still proceed.
884
+ Fields degrade gracefully on RPC failure so a resume gate can still proceed.
904
885
  """
905
886
  lid = str(loop_id or "").strip()
906
887
  empty = SimpleNamespace(
@@ -936,7 +917,7 @@ class DaemonSession:
936
917
  iteration = iteration_raw if isinstance(iteration_raw, int) else 0
937
918
  status_raw = resp.get("status")
938
919
  status = status_raw if isinstance(status_raw, str) else None
939
- # ``active_runner`` is absent on daemons older than this field; treat
920
+ # `active_runner` is absent on daemons older than this field; treat
940
921
  # absence as unknown (None) so callers can fall back to status only.
941
922
  active_runner_raw = resp.get("active_runner")
942
923
  active_runner: bool | None
@@ -987,7 +968,7 @@ class DaemonSession:
987
968
  return [m for m in raw if isinstance(m, dict)]
988
969
 
989
970
  async def fetch_goal_completion_text(self, loop_id: str) -> str | None:
990
- """Return the latest persisted ``goal_completion`` body for a loop, if any."""
971
+ """Return the latest persisted `goal_completion` body for a loop, if any."""
991
972
  rows = await self.fetch_conversation_log(loop_id, limit=200, include_events=False)
992
973
  for row in reversed(rows):
993
974
  if row.get("phase") != "goal_completion":
@@ -6,23 +6,17 @@ from typing import Any
6
6
 
7
7
 
8
8
  def unwrap_next(event: dict[str, Any] | None) -> dict[str, Any] | None:
9
- """Unwrap a protocol-1 ``next`` envelope to its inner streaming frame.
9
+ """Unwrap a protocol-1 `next` envelope to its inner streaming frame.
10
10
 
11
- Under protocol-1 the daemon wraps free-form streaming frames
12
- (``event`` / ``command_response`` / card replay) in a
13
- ``{proto, type:"next", payload:{namespace, mode, data}}`` envelope. This
14
- helper returns the inner ``data`` dict (the legacy frame) so turn loops
15
- can branch on the same fields as before the migration.
16
-
17
- ``status`` / ``error`` / ``response`` / ``complete`` are sent raw and pass
18
- through unchanged.
11
+ Returns the inner `payload.data` dict for `next` envelopes; other
12
+ types pass through unchanged.
19
13
 
20
14
  Args:
21
- event: A raw wire frame as returned by ``client.read_event()``.
15
+ event: A raw wire frame from `client.read_event()`.
22
16
 
23
17
  Returns:
24
- The inner ``payload.data`` dict for ``next`` envelopes, the original
25
- frame otherwise, or ``None`` if ``event`` is ``None``.
18
+ The inner `payload.data` dict for `next`, the original frame
19
+ otherwise, or `None` if `event` is `None`.
26
20
  """
27
21
  if not isinstance(event, dict):
28
22
  return event
@@ -38,16 +32,15 @@ def unwrap_next(event: dict[str, Any] | None) -> dict[str, Any] | None:
38
32
  def is_loop_scoped_event(event: dict[str, Any], *, active_loop_id: str) -> bool:
39
33
  """Return whether a daemon frame belongs to the active StrangeLoop session.
40
34
 
41
- Unwraps protocol-1 ``next`` envelopes first, then checks ``loop_id`` on the
42
- inner streaming frame. Non-scoped types (``response``, ``error``,
43
- ``complete``, etc.) are always considered in-scope.
35
+ Unwraps `next` envelopes first, then checks `loop_id`. Non-scoped types
36
+ (`response`, `error`, `complete`) are always in-scope.
44
37
 
45
38
  Args:
46
39
  event: Raw or already-unwrapped wire frame.
47
40
  active_loop_id: Loop id for the in-flight session.
48
41
 
49
42
  Returns:
50
- True when the frame should be processed for ``active_loop_id``.
43
+ True when the frame should be processed for `active_loop_id`.
51
44
  """
52
45
  event_type = event.get("type", "")
53
46
  if event_type == "next":
@@ -67,7 +67,7 @@ class LoopSessionStore(Protocol):
67
67
  """Persistence seam between appkit and the application's storage backend."""
68
68
 
69
69
  async def get_session(self, session_id: str) -> LoopSessionEntry | None:
70
- """Return the persisted entry for ``session_id``, or None."""
70
+ """Return the persisted entry for `session_id`, or None."""
71
71
  ...
72
72
 
73
73
  async def create_session(
@@ -89,7 +89,7 @@ class LoopSessionStore(Protocol):
89
89
  ...
90
90
 
91
91
  async def get_loop_id_for_session(self, session_id: str) -> tuple[str, bool]:
92
- """Return ``(loop_id, ok)``; ``ok is False`` triggers fresh ``loop_new``."""
92
+ """Return `(loop_id, ok)`; `ok is False` triggers fresh `loop_new`."""
93
93
  ...
94
94
 
95
95
  async def append_message(self, session_id: str, message: SessionMessage) -> None:
@@ -1,7 +1,7 @@
1
1
  """ManagedClient adapter used by ConnectionPool and TurnRunner.
2
2
 
3
- Wraps ``WebSocketClient`` and upgrades flat appkit payloads
4
- (``loop_input``, ``command_request``, …) to protocol-1 envelopes before send.
3
+ Wraps `WebSocketClient` and upgrades flat appkit payloads to protocol-1
4
+ envelopes before send.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -18,7 +18,7 @@ BootstrapFunc = Callable[
18
18
  Awaitable[str],
19
19
  ]
20
20
 
21
- # Flat appkit payloads still use legacy ``{"type": "<op>", ...}`` maps (parity
21
+ # Flat appkit payloads still use legacy `{"type": "<op>", ...}` maps (parity
22
22
  # with Go InputMessageForLoop). The live daemon accepts protocol-1 envelopes
23
23
  # only — coerce at the ManagedClient boundary before send.
24
24
  _ALREADY_ENVELOPE_TYPES = frozenset(
@@ -133,7 +133,7 @@ class ManagedClient(Protocol):
133
133
 
134
134
 
135
135
  class WebSocketManagedClient:
136
- """Adapter wrapping ``WebSocketClient`` as a ``ManagedClient``."""
136
+ """Adapter wrapping `WebSocketClient` as a `ManagedClient`."""
137
137
 
138
138
  def __init__(self, client: WebSocketClient) -> None:
139
139
  self._client = client
@@ -189,7 +189,7 @@ class WebSocketManagedClient:
189
189
 
190
190
 
191
191
  def default_client_factory() -> ClientFactory:
192
- """Return a factory that builds ``WebSocketManagedClient`` instances."""
192
+ """Return a factory that builds `WebSocketManagedClient` instances."""
193
193
 
194
194
  def _factory(url: str) -> ManagedClient:
195
195
  return WebSocketManagedClient(WebSocketClient(url=url))
@@ -198,7 +198,7 @@ def default_client_factory() -> ClientFactory:
198
198
 
199
199
 
200
200
  def default_bootstrap_func() -> BootstrapFunc:
201
- """Default bootstrap: ``loop_new`` + ``subscribe(loop_events)``."""
201
+ """Default bootstrap: `loop_new` + `subscribe(loop_events)`."""
202
202
 
203
203
  async def _bootstrap(client: ManagedClient, workspace_id: str, user_id: str) -> str:
204
204
  from soothe_client.session import bootstrap_loop_session
@@ -9,7 +9,7 @@ from dataclasses import dataclass
9
9
  class TurnEventStats:
10
10
  """Event counts accumulated over a single daemon turn.
11
11
 
12
- Apps may subclass or replace this via ``DaemonSession``'s stats factory.
12
+ Apps may subclass or replace this via `DaemonSession`'s stats factory.
13
13
  """
14
14
 
15
15
  total: int = 0
@@ -3,7 +3,7 @@
3
3
  Manages a pool of daemon connections, one active per session. Reuses an
4
4
  active connection when still live, otherwise bootstraps a fresh loop or
5
5
  reattaches an existing one. Persistence of session↔loop mappings is
6
- abstracted behind ``LoopSessionStore``.
6
+ abstracted behind `LoopSessionStore`.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -35,11 +35,7 @@ class ErrPoolExhausted(Exception): # noqa: N818 — match Go/TS name
35
35
 
36
36
  @dataclass(slots=True)
37
37
  class PoolConfig:
38
- """Configures a ``ConnectionPool``. Zero / negative sizes use defaults.
39
-
40
- ``max_idle_time_s`` is enforced on ``acquire`` (idle sessions are released).
41
- ``health_check_interval_s`` is reserved for future background sweeps.
42
- """
38
+ """Configures a `ConnectionPool`. Zero / negative sizes use defaults."""
43
39
 
44
40
  pool_size: int = 1000
45
41
  query_timeout_s: float = 30 * 60
@@ -145,9 +141,9 @@ class ConnectionPool:
145
141
  workspace_id: str,
146
142
  user_id: str,
147
143
  ) -> PooledConn:
148
- """Return a live connection for ``session_id``.
144
+ """Return a live connection for `session_id`.
149
145
 
150
- Caller must ``release`` when done (turn completes or session reset).
146
+ Caller must `release` when done (turn completes or session reset).
151
147
  """
152
148
  async with self._lock:
153
149
  existing = self._active.get(session_id)
@@ -209,7 +205,7 @@ class ConnectionPool:
209
205
  return conn
210
206
 
211
207
  async def release(self, session_id: str) -> None:
212
- """Tear down the connection for ``session_id`` and return a fresh slot."""
208
+ """Tear down the connection for `session_id` and return a fresh slot."""
213
209
  async with self._lock:
214
210
  await self._release_unlocked(session_id)
215
211
 
@@ -35,7 +35,7 @@ class QueryGate:
35
35
  cancel: Callable[[], None],
36
36
  send_cancel: SendCancelFn | None = None,
37
37
  ) -> None:
38
- """Reserve ``session_id`` for one agent turn.
38
+ """Reserve `session_id` for one agent turn.
39
39
 
40
40
  Args:
41
41
  session_id: Application session key (often the loop id).
@@ -43,7 +43,7 @@ class QueryGate:
43
43
  send_cancel: Optional async daemon-cancel sender.
44
44
 
45
45
  Raises:
46
- ErrQueryBusy: A query is already in flight for ``session_id``.
46
+ ErrQueryBusy: A query is already in flight for `session_id`.
47
47
  """
48
48
  async with self._lock:
49
49
  if session_id in self._cancels:
@@ -80,7 +80,7 @@ class QueryGate:
80
80
  self._send_cancels.pop(session_id, None)
81
81
 
82
82
  async def is_active(self, session_id: str) -> bool:
83
- """Return whether a query is in flight for ``session_id``."""
83
+ """Return whether a query is in flight for `session_id`."""
84
84
  async with self._lock:
85
85
  return session_id in self._cancels
86
86
 
@@ -1,7 +1,6 @@
1
1
  """Thinking-step extraction for appkit.
2
2
 
3
- Maps an allowlisted progress event to one structured UI line. Free-form
4
- streams (tokens, reports, reasoning) are excluded. Ported from Go/TS appkit.
3
+ Maps an allowlisted progress event to one structured UI line.
5
4
  """
6
5
 
7
6
  from __future__ import annotations
@@ -40,7 +39,7 @@ def extract_thinking_step(
40
39
  allow: Optional override of the default thinking-step allowlist.
41
40
 
42
41
  Returns:
43
- ``(line, True)`` for a recognized event; ``("", False)`` otherwise.
42
+ `(line, True)` for a recognized event; `("", False)` otherwise.
44
43
  """
45
44
  if not event_type or data is None:
46
45
  return "", False