zettcode 0.3.2__tar.gz → 0.3.3__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 (365) hide show
  1. {zettcode-0.3.2 → zettcode-0.3.3}/PKG-INFO +2 -2
  2. {zettcode-0.3.2 → zettcode-0.3.3}/pyproject.toml +1 -1
  3. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/__init__.py +1 -1
  4. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/cli.py +48 -3
  5. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/client.py +26 -7
  6. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/finder.py +2 -2
  7. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/hub.py +69 -5
  8. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/models.py +3 -1
  9. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/protocol.py +96 -8
  10. zettcode-0.3.3/src/zettcode/broker/reload.py +93 -0
  11. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/store.py +68 -13
  12. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/web.py +8 -6
  13. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/cli.py +11 -2
  14. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/daemon/run.py +18 -2
  15. zettcode-0.3.3/src/zettcode/engine/extensions/guidance.py +17 -0
  16. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/subagents.py +4 -4
  17. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/messenger.py +13 -4
  18. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/builtin_agent.py +4 -4
  19. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/app.py +20 -5
  20. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/backend.py +7 -6
  21. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/chat.py +18 -4
  22. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/service.py +167 -8
  23. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/app.css +36 -0
  24. zettcode-0.3.3/src/zettcode/gateway/static/app.js +3 -0
  25. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/relay.py +3 -1
  26. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/client.py +18 -4
  27. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/types.py +8 -0
  28. zettcode-0.3.2/src/zettcode/gateway/static/app.js +0 -3
  29. {zettcode-0.3.2 → zettcode-0.3.3}/.gitignore +0 -0
  30. {zettcode-0.3.2 → zettcode-0.3.3}/README.md +0 -0
  31. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/_compat.py +0 -0
  32. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/__init__.py +0 -0
  33. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/__init__.py +0 -0
  34. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/agent/__init__.py +0 -0
  35. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/agent/commands.py +0 -0
  36. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/agent/settings.py +0 -0
  37. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/__init__.py +0 -0
  38. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/changes.py +0 -0
  39. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/effort_gauge.py +0 -0
  40. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/entries.py +0 -0
  41. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/presenters.py +0 -0
  42. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/projection.py +0 -0
  43. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/pulse.py +0 -0
  44. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/rendering.py +0 -0
  45. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/replay.py +0 -0
  46. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/shimmer.py +0 -0
  47. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/stops.py +0 -0
  48. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/text.py +0 -0
  49. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/tips.py +0 -0
  50. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/transcript/__init__.py +0 -0
  51. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/transcript/fold.py +0 -0
  52. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/transcript/log.py +0 -0
  53. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/transcript/oneoff.py +0 -0
  54. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/transcript/stream.py +0 -0
  55. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/agent/conversation/transcript/turn.py +0 -0
  56. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/brand.py +0 -0
  57. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/commandline/__init__.py +0 -0
  58. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/commandline/arguments.py +0 -0
  59. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/commandline/completion.py +0 -0
  60. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/commandline/history.py +0 -0
  61. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/commandline/runner.py +0 -0
  62. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/commands.py +0 -0
  63. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/__init__.py +0 -0
  64. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/cat.py +0 -0
  65. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/chibi.py +0 -0
  66. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/dog.py +0 -0
  67. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/kangaroo.py +0 -0
  68. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/model.py +0 -0
  69. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/penguin.py +0 -0
  70. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/polarbear.py +0 -0
  71. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/scarfy.py +0 -0
  72. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/seagull.py +0 -0
  73. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/slime.py +0 -0
  74. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/snowbear.py +0 -0
  75. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/sprout.py +0 -0
  76. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/tiger.py +0 -0
  77. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/pets/whale.py +0 -0
  78. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/__init__.py +0 -0
  79. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/builtin_commands.py +0 -0
  80. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/builtins.py +0 -0
  81. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/groups.py +0 -0
  82. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/host.py +0 -0
  83. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/loader.py +0 -0
  84. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/mixins.py +0 -0
  85. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/plugin.py +0 -0
  86. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/plugins/state.py +0 -0
  87. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/__init__.py +0 -0
  88. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/app.py +0 -0
  89. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/bars.py +0 -0
  90. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/boot.py +0 -0
  91. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/clipboard.py +0 -0
  92. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/commands.py +0 -0
  93. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/config_form.py +0 -0
  94. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/demo.py +0 -0
  95. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/followups.py +0 -0
  96. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/gc_policy.py +0 -0
  97. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/keys.py +0 -0
  98. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/labels.py +0 -0
  99. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/messaging.py +0 -0
  100. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/notices.py +0 -0
  101. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/providers.py +0 -0
  102. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/sessions.py +0 -0
  103. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/settings.py +0 -0
  104. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/shell.py +0 -0
  105. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/turns.py +0 -0
  106. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/updating.py +0 -0
  107. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/__init__.py +0 -0
  108. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/approval_page.py +0 -0
  109. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/ask_page.py +0 -0
  110. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/bash_page.py +0 -0
  111. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/completer.py +0 -0
  112. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/composer.py +0 -0
  113. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/context_charts.py +0 -0
  114. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/context_page.py +0 -0
  115. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/document_page.py +0 -0
  116. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/effort_page.py +0 -0
  117. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/follow_up.py +0 -0
  118. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/form_page.py +0 -0
  119. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/harness_page.py +0 -0
  120. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/input_page.py +0 -0
  121. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/model_page.py +0 -0
  122. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/model_settings_page.py +0 -0
  123. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/panel.py +0 -0
  124. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/pet.py +0 -0
  125. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/pet_page.py +0 -0
  126. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/plugins_page.py +0 -0
  127. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/root.py +0 -0
  128. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/self_compact_page.py +0 -0
  129. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/send_kind_page.py +0 -0
  130. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/sessions_page.py +0 -0
  131. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/statusline_page.py +0 -0
  132. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/steering.py +0 -0
  133. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/switch.py +0 -0
  134. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/transcript.py +0 -0
  135. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/update_page.py +0 -0
  136. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/usage_charts.py +0 -0
  137. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/usage_page.py +0 -0
  138. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/app/ui/widgets/welcome.py +0 -0
  139. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/__init__.py +0 -0
  140. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/__main__.py +0 -0
  141. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/files.py +0 -0
  142. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/log.py +0 -0
  143. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/process.py +0 -0
  144. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/roster.py +0 -0
  145. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/runner.py +0 -0
  146. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/server.py +0 -0
  147. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/broker/text.py +0 -0
  148. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/budget.py +0 -0
  149. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/collector.py +0 -0
  150. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/config.py +0 -0
  151. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/config_edit.py +0 -0
  152. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/daemon/__init__.py +0 -0
  153. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/daemon/__main__.py +0 -0
  154. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/daemon/cli.py +0 -0
  155. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/daemon/companions.py +0 -0
  156. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/daemon/process.py +0 -0
  157. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/daemon/state.py +0 -0
  158. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/doctor.py +0 -0
  159. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/__init__.py +0 -0
  160. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/__init__.py +0 -0
  161. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/base.py +0 -0
  162. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/context.py +0 -0
  163. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/exports.py +0 -0
  164. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/interaction.py +0 -0
  165. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/sessions.py +0 -0
  166. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/settings.py +0 -0
  167. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/agent/turns.py +0 -0
  168. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/core.py +0 -0
  169. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/dialogue/__init__.py +0 -0
  170. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/dialogue/ask.py +0 -0
  171. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/dialogue/side.py +0 -0
  172. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/dialogue/title.py +0 -0
  173. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/export/__init__.py +0 -0
  174. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/export/trace/script.js +0 -0
  175. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/export/trace/style.css +0 -0
  176. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/export/trace/template.html +0 -0
  177. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/__init__.py +0 -0
  178. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/capabilities.py +0 -0
  179. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/compaction.py +0 -0
  180. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/context.py +0 -0
  181. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/files.py +0 -0
  182. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/harness.py +0 -0
  183. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/harness_switch.py +0 -0
  184. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/mcp.py +0 -0
  185. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/mcp_notice.py +0 -0
  186. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/messaging.py +0 -0
  187. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/relay.py +0 -0
  188. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/tool_output.py +0 -0
  189. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/truncation.py +0 -0
  190. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/extensions/usage.py +0 -0
  191. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/harness_paths.py +0 -0
  192. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/permission/__init__.py +0 -0
  193. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/permission/approval.py +0 -0
  194. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/permission/readonly.py +0 -0
  195. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/permission/sandbox.py +0 -0
  196. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/pipeline.py +0 -0
  197. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/__init__.py +0 -0
  198. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/commands.py +0 -0
  199. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/context.py +0 -0
  200. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/extension.py +0 -0
  201. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/groups.py +0 -0
  202. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/loader.py +0 -0
  203. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/mixins.py +0 -0
  204. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/plugin.py +0 -0
  205. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/services.py +0 -0
  206. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/settings.py +0 -0
  207. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/state.py +0 -0
  208. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/plugins/switches.py +0 -0
  209. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/server_tools.py +0 -0
  210. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/storage/__init__.py +0 -0
  211. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/storage/layout.py +0 -0
  212. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/storage/ledger.py +0 -0
  213. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/storage/locks.py +0 -0
  214. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/storage/metadata.py +0 -0
  215. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/storage/records.py +0 -0
  216. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/storage/store.py +0 -0
  217. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/tools/__init__.py +0 -0
  218. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/tools/boundary.py +0 -0
  219. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/tools/files.py +0 -0
  220. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/tools/images.py +0 -0
  221. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/tools/output.py +0 -0
  222. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/tools/shell.py +0 -0
  223. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/engine/tools/workspace.py +0 -0
  224. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/error_text.py +0 -0
  225. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/filelock.py +0 -0
  226. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/__init__.py +0 -0
  227. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/cli.py +0 -0
  228. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/runfile.py +0 -0
  229. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/settings.py +0 -0
  230. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  231. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  232. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  233. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  234. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  235. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  236. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  237. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  238. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  239. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  240. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  241. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  242. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  243. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  244. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  245. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  246. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  247. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  248. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  249. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  250. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +0 -0
  251. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/chunks/render-G6G3TSB3.js +0 -0
  252. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/icon.png +0 -0
  253. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/index.html +0 -0
  254. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/logo.svg +0 -0
  255. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/gateway/static/manifest.webmanifest +0 -0
  256. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/logging_setup.py +0 -0
  257. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/mentions.py +0 -0
  258. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/modes.py +0 -0
  259. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/oneshot.py +0 -0
  260. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/paths.py +0 -0
  261. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/plugin_cli.py +0 -0
  262. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/plugin_tui.py +0 -0
  263. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/processes.py +0 -0
  264. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/prompt.py +0 -0
  265. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/providers.py +0 -0
  266. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/py.typed +0 -0
  267. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/registry.py +0 -0
  268. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/__init__.py +0 -0
  269. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/commands.py +0 -0
  270. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/daemons.py +0 -0
  271. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/errors.py +0 -0
  272. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/events.py +0 -0
  273. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/handlers.py +0 -0
  274. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/models.py +0 -0
  275. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/session.py +0 -0
  276. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/sdk/translation.py +0 -0
  277. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/server/__init__.py +0 -0
  278. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/server/app.py +0 -0
  279. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/server/cli.py +0 -0
  280. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/server/runner.py +0 -0
  281. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/server/schemas.py +0 -0
  282. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/server/serialize.py +0 -0
  283. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/server/settings.py +0 -0
  284. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/statusline.py +0 -0
  285. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/table.py +0 -0
  286. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/__init__.py +0 -0
  287. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/capabilities.py +0 -0
  288. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/clipboard.py +0 -0
  289. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/console.py +0 -0
  290. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/__init__.py +0 -0
  291. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/app.py +0 -0
  292. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/events.py +0 -0
  293. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/focus.py +0 -0
  294. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/geometry.py +0 -0
  295. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/host.py +0 -0
  296. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/keymap.py +0 -0
  297. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/scheduler.py +0 -0
  298. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/screen.py +0 -0
  299. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/theme.py +0 -0
  300. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/core/widget.py +0 -0
  301. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/gallery.py +0 -0
  302. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/glyphs.py +0 -0
  303. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/input/__init__.py +0 -0
  304. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/input/bridge.py +0 -0
  305. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/input/decoder.py +0 -0
  306. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/input/events.py +0 -0
  307. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/input/keys.py +0 -0
  308. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/input/reader.py +0 -0
  309. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/layout/__init__.py +0 -0
  310. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/layout/box.py +0 -0
  311. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/layout/overlay.py +0 -0
  312. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/layout/scroll.py +0 -0
  313. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/layout/solver.py +0 -0
  314. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/layout/spacing.py +0 -0
  315. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/__init__.py +0 -0
  316. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/ansi.py +0 -0
  317. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/canvas.py +0 -0
  318. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/code.py +0 -0
  319. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/color.py +0 -0
  320. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/renderer.py +0 -0
  321. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/rich_text.py +0 -0
  322. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/style.py +0 -0
  323. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/sweep.py +0 -0
  324. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/render/text.py +0 -0
  325. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/runner.py +0 -0
  326. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/terminal.py +0 -0
  327. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/testing/__init__.py +0 -0
  328. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/testing/harness.py +0 -0
  329. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/testing/snapshot.py +0 -0
  330. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/theme_file.py +0 -0
  331. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/ayu-mirage.toml +0 -0
  332. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/catppuccin-latte.toml +0 -0
  333. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/catppuccin-mocha.toml +0 -0
  334. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/dracula.toml +0 -0
  335. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/github-dimmed.toml +0 -0
  336. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/github-light.toml +0 -0
  337. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/gruvbox-dark.toml +0 -0
  338. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/gruvbox-light.toml +0 -0
  339. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/kanagawa.toml +0 -0
  340. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/nord.toml +0 -0
  341. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/one-dark.toml +0 -0
  342. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/rose-pine-dawn.toml +0 -0
  343. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/rose-pine.toml +0 -0
  344. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/solarized-dark.toml +0 -0
  345. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/solarized-light.toml +0 -0
  346. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/themes/tokyo-night.toml +0 -0
  347. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/__init__.py +0 -0
  348. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/collapsible.py +0 -0
  349. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/completion.py +0 -0
  350. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/console.py +0 -0
  351. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/dialog.py +0 -0
  352. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/diff.py +0 -0
  353. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/list.py +0 -0
  354. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/list_page.py +0 -0
  355. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/markdown.py +0 -0
  356. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/progress.py +0 -0
  357. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/rich_text.py +0 -0
  358. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/status.py +0 -0
  359. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/table.py +0 -0
  360. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/tasks.py +0 -0
  361. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/text.py +0 -0
  362. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/textarea.py +0 -0
  363. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/tui/widgets/toast.py +0 -0
  364. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/update.py +0 -0
  365. {zettcode-0.3.2 → zettcode-0.3.3}/src/zettcode/wording.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: A focused terminal coding agent powered by zett-agent
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Project-URL: Issues, https://github.com/Chang-LeHung/zettcode/issues
@@ -35,7 +35,7 @@ Requires-Dist: truststore<1,>=0.10
35
35
  Requires-Dist: uvicorn<1,>=0.35
36
36
  Requires-Dist: wcwidth<1,>=0.2
37
37
  Requires-Dist: websockets<17,>=15
38
- Requires-Dist: zett-agent<0.3,>=0.2.4
38
+ Requires-Dist: zett-agent<0.3,>=0.2.6
39
39
  Provides-Extra: all
40
40
  Requires-Dist: zettcode-monitor<0.3,>=0.2; extra == 'all'
41
41
  Requires-Dist: zettcode-recap<0.2,>=0.1; extra == 'all'
@@ -61,7 +61,7 @@ dependencies = [
61
61
  # now leans on: a hosted tool the provider left open is closed with
62
62
  # ``output={"status": "not_reported"}`` instead of leaving the step waiting, and
63
63
  # `engine/server_tools.describe_output` reads that to say so.
64
- "zett-agent>=0.2.4,<0.3",
64
+ "zett-agent>=0.2.6,<0.3",
65
65
  ]
66
66
 
67
67
  [project.optional-dependencies]
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
7
7
 
8
8
  #: The one place the version is written. ``pyproject.toml`` reads it from here
9
9
  #: and the release workflow checks the tag against it, so a bump is one edit.
10
- __version__ = "0.3.2"
10
+ __version__ = "0.3.3"
11
11
 
12
12
  #: Where each public name lives; read by :func:`__getattr__` on first use.
13
13
  _EXPORTS = {
@@ -22,6 +22,8 @@ from ..filelock import is_held
22
22
  from . import process
23
23
  from .files import MODULE, Note, broker_dir, lock_path, log_path, read_note, remove_note
24
24
  from .finder import spawn_broker
25
+ from .protocol import PROTOCOL_VERSION
26
+ from .reload import outdated
25
27
 
26
28
  EXIT_OK = 0
27
29
  EXIT_FAILED = 1
@@ -65,8 +67,11 @@ def build_parser() -> argparse.ArgumentParser:
65
67
  )
66
68
  parser.add_argument(
67
69
  "action",
68
- choices=("start", "stop", "restart"),
69
- help="start a broker if there is none, stop the one there is, or do both",
70
+ choices=("start", "stop", "restart", "reload"),
71
+ help=(
72
+ "start a broker if there is none, stop the one there is, or do both; reload restarts it only when it is "
73
+ "older than this version and no background agent is on it (zettcode runs this by itself after an upgrade)"
74
+ ),
70
75
  )
71
76
  parser.add_argument(
72
77
  "-y",
@@ -88,10 +93,44 @@ def broker_program(argv: list[str] | None = None, companions: Companions | None
88
93
  directory = broker_dir()
89
94
  if args.action == "restart":
90
95
  return restart(directory, companions, assume_yes=args.yes)
96
+ if args.action == "reload":
97
+ return reload(directory, companions)
91
98
  action = {"start": start, "stop": stop}[args.action]
92
99
  return action(directory)
93
100
 
94
101
 
102
+ def reload(directory: Path, companions: Companions | None = None) -> int:
103
+ """Replace the running broker when it is older than this version, and say nothing when there is nothing to do.
104
+
105
+ This is what runs behind every start of ``zettcode`` after an upgrade (:mod:`.reload`), so it never asks and never
106
+ stops what it should not: a broker with background agents on it is left as it is, with a line that says how to
107
+ restart it knowingly, because stopping them is something a person agrees to (``restart`` asks). A broker that is
108
+ current, newer, or not running is not touched. The status is 0 unless a replacement was tried and failed.
109
+ """
110
+ note = outdated(directory)
111
+ if note is None:
112
+ return EXIT_OK
113
+ if companions is not None and (on_it := companions.listing()):
114
+ count = len(on_it)
115
+ print(
116
+ f"the broker (pid {note.pid}) speaks an older protocol, but {count} background "
117
+ f"agent{'s are' if count != 1 else ' is'} running on it, so it was left alone: "
118
+ "run `zettcode broker restart` when that is fine"
119
+ )
120
+ return EXIT_OK
121
+ # `stop` compares the note itself, in the same step that ends the broker, so what it ends is the broker that was
122
+ # found and never one another start of zettcode has just put in its place.
123
+ if stop(directory, expect=note.pid) != EXIT_OK:
124
+ return EXIT_FAILED
125
+ if running(directory):
126
+ # `stop` left a broker that is not the one that was found: another start replaced it first, and it is theirs.
127
+ return EXIT_OK
128
+ status = start(directory)
129
+ if status == EXIT_OK:
130
+ print(f"replaced broker {note.pid} (protocol {note.version}) with one that speaks {PROTOCOL_VERSION}")
131
+ return status
132
+
133
+
95
134
  def running(directory: Path) -> bool:
96
135
  """Return whether a broker holds its lock in ``directory``, which is the only reliable sign that one is there."""
97
136
  return is_held(lock_path(directory))
@@ -118,7 +157,7 @@ def start(directory: Path) -> int:
118
157
  return EXIT_OK
119
158
 
120
159
 
121
- def stop(directory: Path) -> int:
160
+ def stop(directory: Path, expect: int | None = None) -> int:
122
161
  """Stop the broker that is running, and say what happened.
123
162
 
124
163
  A note whose broker has gone is removed rather than reported: it is what the next session would read, and a
@@ -127,8 +166,14 @@ def stop(directory: Path) -> int:
127
166
  An explicit ``stop`` does not compare the broker's protocol version: that rule (never end a newer broker) is for
128
167
  a session replacing one automatically, so two versions do not end each other's in turn; a person who asks for
129
168
  this wants it stopped, whatever it says it speaks.
169
+
170
+ With ``expect``, only the broker with that process id is ended: the note is read once, here, and a broker it does
171
+ not name (another start of zettcode replaced the one that was meant) is left running. That is a compare and a stop
172
+ in one step, which a check made before calling this cannot be.
130
173
  """
131
174
  note = read_note(directory)
175
+ if expect is not None and (note is None or note.pid != expect):
176
+ return EXIT_OK
132
177
  if not running(directory):
133
178
  if note is not None:
134
179
  remove_note(directory, note.token)
@@ -25,7 +25,7 @@ import itertools
25
25
  import json
26
26
  import time
27
27
  from collections import deque
28
- from collections.abc import Callable
28
+ from collections.abc import Callable, Sequence
29
29
  from pathlib import Path
30
30
  from typing import Any
31
31
 
@@ -38,9 +38,11 @@ from .protocol import (
38
38
  VIA_AGENT,
39
39
  VIA_USER,
40
40
  VIAS,
41
+ ImageData,
41
42
  Limits,
42
43
  PeerInfo,
43
44
  Refused,
45
+ parse_images,
44
46
  roster_from_wire,
45
47
  )
46
48
  from .text import clean_text
@@ -346,18 +348,28 @@ class BrokerClient:
346
348
 
347
349
  # -- sending ----------------------------------------------------------------------------------
348
350
  async def send(
349
- self, to: str, text: str, *, hops: int = 0, via: str = VIA_AGENT, direct: bool = False, mode: str = MODE_STEER
351
+ self,
352
+ to: str,
353
+ text: str,
354
+ *,
355
+ hops: int = 0,
356
+ via: str = VIA_AGENT,
357
+ direct: bool = False,
358
+ mode: str = MODE_STEER,
359
+ images: Sequence[ImageData] = (),
350
360
  ) -> Delivered:
351
361
  """Send a message to another session and return once it has taken it.
352
362
 
353
363
  Args:
354
364
  to: The session's handle or id.
355
- text: What to say.
365
+ text: What to say; it may be empty when ``images`` is not.
356
366
  hops: How many sessions it has passed through already.
357
367
  via: How it came: the reader, the agent, or an external program.
358
368
  direct: Ask a session that can to run it as its own reader's prompt and answer the sender.
359
369
  mode: What a busy recipient does with it: ``"steer"`` (the default) hands it to the running request,
360
370
  ``"followup"`` waits until that request ends.
371
+ images: Pictures to send with it. Only a ``direct`` message from a reader may carry them, and the broker
372
+ refuses them for any other.
361
373
 
362
374
  Raises:
363
375
  SendError: There is no broker, or the broker or the recipient refused, or nobody answered in time, or
@@ -365,10 +377,12 @@ class BrokerClient:
365
377
  """
366
378
  if mode not in MODES:
367
379
  raise SendError("invalid", "mode is 'steer' or 'followup'")
368
- cleaned = self._checked(text)
369
- body = {"to": to, "text": cleaned, "via": via, "hops": hops, "mode": mode}
380
+ cleaned = self._checked(text, silent=bool(images))
381
+ body: dict[str, Any] = {"to": to, "text": cleaned, "via": via, "hops": hops, "mode": mode}
370
382
  if direct:
371
383
  body["direct"] = True
384
+ if images:
385
+ body["images"] = [picture.to_wire() for picture in images]
372
386
  return self._delivered(await self._request("POST", "/messages", json=body, send=True))
373
387
 
374
388
  async def send_group(
@@ -398,9 +412,9 @@ class BrokerClient:
398
412
  )
399
413
  return GroupDelivered(name if isinstance(name, str) else group, tuple(delivered), failed)
400
414
 
401
- def _checked(self, text: str) -> str:
415
+ def _checked(self, text: str, *, silent: bool = False) -> str:
402
416
  cleaned = clean_text(text)
403
- if not cleaned.strip():
417
+ if not cleaned.strip() and not silent:
404
418
  raise SendError("empty", "a message needs some text")
405
419
  if len(cleaned) > self.limits.max_text:
406
420
  raise SendError("too_long", f"a message is limited to {self.limits.max_text} characters")
@@ -679,6 +693,10 @@ class BrokerClient:
679
693
  mode = MODE_STEER # a peer that does not say: the protocol's default
680
694
  elif mode not in MODES:
681
695
  mode = MODE_FOLLOWUP # one this version does not know: the safe half, not the disruptive one
696
+ try:
697
+ pictures = parse_images(line.get("images")) if line.get("direct") is True and via == VIA_USER else ()
698
+ except Refused:
699
+ return _ack(Receipt(False, "the message's pictures were malformed"))
682
700
  incoming = Incoming(
683
701
  sender,
684
702
  clean_text(text),
@@ -687,6 +705,7 @@ class BrokerClient:
687
705
  line.get("direct") is True and via == VIA_USER,
688
706
  mode,
689
707
  line["group"] if isinstance(line.get("group"), str) else "",
708
+ pictures,
690
709
  )
691
710
  try:
692
711
  return _ack(self.on_message(incoming))
@@ -30,7 +30,7 @@ from .. import logging_setup
30
30
  from ..filelock import is_held
31
31
  from . import process
32
32
  from .files import MODULE, Note, broker_dir, ensure_directory, lock_path, log_path, read_note
33
- from .protocol import MAX_BODY, PROTOCOL_VERSION
33
+ from .protocol import MAX_MESSAGE_BODY, PROTOCOL_VERSION
34
34
 
35
35
  #: A broker is started at most this often, so one that dies at once is not restarted in a loop.
36
36
  SPAWN_INTERVAL = 3.0
@@ -152,7 +152,7 @@ class Finder:
152
152
  additional_headers={"Authorization": f"Bearer {note.token}"},
153
153
  proxy=None, # never through a proxy named in the environment: the broker is on this machine
154
154
  compression=None,
155
- max_size=MAX_BODY,
155
+ max_size=MAX_MESSAGE_BODY,
156
156
  open_timeout=self.connect_timeout,
157
157
  )
158
158
  except InvalidStatus as refused:
@@ -39,20 +39,27 @@ from .protocol import (
39
39
  STREAM_TYPES,
40
40
  VIA_EXTERNAL,
41
41
  VIA_USER,
42
+ ImageData,
42
43
  Limits,
43
44
  PeerInfo,
44
45
  Refused,
45
46
  SessionUpdate,
46
47
  group_name,
48
+ parse_images,
47
49
  parse_send,
48
50
  )
49
51
  from .roster import Entry, Roster
50
- from .store import KIND_GROUP, KIND_SESSION, Store
52
+ from .store import KIND_GROUP, KIND_SESSION, MAX_WINDOW, Store
51
53
  from .text import clean_text, clean_title, slug
52
54
 
53
55
  #: Messages waiting for one session to say it took them. More are told the session is busy.
54
56
  MAX_WAITING = 32
55
57
 
58
+ #: The pictures, as base64, that one session may have waiting for it at once: in messages it has not answered, and in
59
+ #: lines it has not read. A message may carry about 11 MiB of them, and a session that has stopped answering would
60
+ #: otherwise hold hundreds of those in the broker.
61
+ MAX_WAITING_PICTURES = 96 << 20
62
+
56
63
  #: Lines queued for one session to read. A session that is this far behind has stopped reading and is dropped.
57
64
  MAX_QUEUED = 256
58
65
 
@@ -103,6 +110,11 @@ class Session:
103
110
  #: Whether this connection asked to be told of every message that passes (the gateway's graph does).
104
111
  self.observing = False
105
112
  self._waiting: dict[int, asyncio.Future[dict[str, Any]]] = {}
113
+ #: The size, as base64, of the pictures in the messages in ``_waiting``.
114
+ self._weight = 0
115
+ #: The size, as base64, of the pictures in the lines still in ``outbox``, which a message whose ack timed out
116
+ #: leaves behind: a socket that never drains must not be able to hold more than the budget.
117
+ self._queued = 0
106
118
  self._deliveries = itertools.count(1)
107
119
 
108
120
  def push(self, line: dict[str, Any]) -> None:
@@ -113,6 +125,14 @@ class Session:
113
125
  if not self._dropped:
114
126
  self._dropped = True
115
127
  self.close(STOPPED_READING)
128
+ else:
129
+ self._queued += _picture_weight(line)
130
+
131
+ async def next_line(self) -> dict[str, Any]:
132
+ """Take the next queued line, for the writer to send: its pictures stop counting against this session's budget."""
133
+ line = await self.outbox.get()
134
+ self._queued -= _picture_weight(line)
135
+ return line
116
136
 
117
137
  def push_lossy(self, line: dict[str, Any]) -> None:
118
138
  """Queue a line that may be lost: when this session is half a queue behind it is skipped, not dropped for it.
@@ -138,14 +158,19 @@ class Session:
138
158
  """
139
159
  if self.waiting >= MAX_WAITING:
140
160
  raise Refused("busy", "that session has too many messages waiting")
161
+ weight = sum(len(picture.get("data", "")) for picture in message.get("images", ()))
162
+ if weight and self._weight + self._queued + weight > MAX_WAITING_PICTURES:
163
+ raise Refused("busy", "that session has too many pictures waiting")
141
164
  number = next(self._deliveries)
142
165
  ack: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
143
166
  self._waiting[number] = ack
167
+ self._weight += weight
144
168
  try:
145
169
  self.push({"op": "deliver", "id": number, **message})
146
170
  return await asyncio.wait_for(ack, timeout)
147
171
  finally:
148
172
  self._waiting.pop(number, None)
173
+ self._weight -= weight
149
174
 
150
175
  def acked(self, line: dict[str, Any]) -> None:
151
176
  """Take an ack; one for something that was never asked, or already answered, is ignored."""
@@ -307,7 +332,11 @@ class Hub:
307
332
  if sender.external:
308
333
  body = {**body, "via": VIA_EXTERNAL}
309
334
  group = body.get("group")
335
+ # Looked at, not yet decoded: a message that may not carry pictures is turned away before they cost anything.
336
+ offered = body.get("images")
310
337
  if group is not None:
338
+ if offered:
339
+ raise Refused("invalid", "pictures go to one session, not to a group")
311
340
  if body.get("to") not in (None, ""):
312
341
  raise Refused("invalid", "name a session in 'to' or a group in 'group', not both")
313
342
  # The group's name stands where a session's would while the text, the hops and the mode are checked.
@@ -315,7 +344,7 @@ class Hub:
315
344
  if via == VIA_EXTERNAL and not sender.external:
316
345
  raise Refused("invalid", "via is 'user' or 'agent'")
317
346
  return await self._send_group(sender, group_name(group), text, via, hops, mode)
318
- to, text, via, hops, mode = parse_send(body, self.limits)
347
+ to, text, via, hops, mode = parse_send(body, self.limits, may_be_silent=bool(offered))
319
348
  if via == VIA_EXTERNAL and not sender.external:
320
349
  raise Refused("invalid", "via is 'user' or 'agent'")
321
350
  target = self.roster.resolve(to, asker=sender.connection)
@@ -327,13 +356,24 @@ class Hub:
327
356
  # that said it can; for any other the flag is dropped and the message is delivered the ordinary way.
328
357
  if body.get("direct") is True and via == VIA_USER and not sender.external and target.direct:
329
358
  message["direct"] = True
359
+ pictures: tuple[ImageData, ...] = ()
360
+ if offered:
361
+ # Only a session that runs the message as its reader's own prompt has anywhere to put a picture: for any
362
+ # other the pictures would be dropped on the way in, and the sender told it went through.
363
+ if message.get("direct") is not True:
364
+ raise Refused(
365
+ "invalid",
366
+ f"@{target.handle} does not take pictures: it does not run a chat's text as its own prompt",
367
+ )
368
+ pictures = parse_images(offered)
369
+ message["images"] = [picture.to_wire() for picture in pictures]
330
370
  ack = await self._hand_over(recipient, message, target.handle)
331
371
  stored = self._remember(
332
372
  sender,
333
373
  KIND_SESSION,
334
374
  (target.session_id, target.handle, target.title),
335
375
  [target.session_id],
336
- text,
376
+ _with_pictures(text, len(pictures)),
337
377
  via,
338
378
  hops + 1,
339
379
  mode,
@@ -624,11 +664,19 @@ class Hub:
624
664
  def timeline(self, since: float, until: float, limit: int) -> dict[str, Any]:
625
665
  """Return the messages of a stretch of the past, oldest first, for a front end to replay on screen.
626
666
 
627
- Nothing is sent again: this is only what was stored.
667
+ Nothing is sent again: this is only what was stored. When more than ``limit`` messages fall in the stretch the
668
+ newest are returned and ``truncated`` says so, so that a replay can say it is missing the start of the stretch
669
+ rather than draw a flow that is quietly incomplete.
628
670
  """
671
+ # One less than the most a read may return, so that the extra row asked for below is always really read: at the
672
+ # cap itself the count could never exceed the limit, and a stretch of fifty thousand would be called complete.
673
+ limit = max(1, min(limit, MAX_WINDOW - 1))
674
+ found = self.store.window(since, until, limit=limit + 1)
675
+ truncated = len(found) > limit
629
676
  span = self.store.span()
630
677
  return {
631
- "messages": self.store.window(since, until, limit=limit),
678
+ "messages": found[-limit:] if truncated else found,
679
+ "truncated": truncated,
632
680
  "span": list(span) if span else None,
633
681
  "now": time.time(),
634
682
  }
@@ -715,6 +763,22 @@ class Hub:
715
763
  session.push({"op": "roster", "sessions": [entry.to_wire() for entry in self.roster.snapshot()]})
716
764
 
717
765
 
766
+ def _picture_weight(line: Mapping[str, Any]) -> int:
767
+ """Return the size, as base64, of the pictures a line carries: a ``deliver`` is the only line that has any."""
768
+ images = line.get("images")
769
+ if not isinstance(images, list):
770
+ return 0
771
+ return sum(len(item.get("data", "")) for item in images if isinstance(item, dict))
772
+
773
+
774
+ def _with_pictures(text: str, count: int) -> str:
775
+ """Return what the history keeps of a message: its words, and how many pictures went with it, never the pictures."""
776
+ if not count:
777
+ return text
778
+ note = f"[{count} picture{'s' if count != 1 else ''}]"
779
+ return f"{text}\n{note}" if text.strip() else note
780
+
781
+
718
782
  def _count(raw: object, default: int) -> int:
719
783
  """Read a count from a query string; one that is not a number is the default."""
720
784
  try:
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
 
7
7
  from ..modes import MODE_STEER
8
- from .protocol import KIND_ZETTCODE, PeerInfo
8
+ from .protocol import KIND_ZETTCODE, ImageData, PeerInfo
9
9
 
10
10
  #: Another word for a registered session, as the rest of the application knows it.
11
11
  RosterEntry = PeerInfo
@@ -38,6 +38,7 @@ class Incoming:
38
38
  mode: What a busy recipient should do with it: ``"steer"`` (the default) hands it to the request that is
39
39
  running, and ``"followup"`` waits until that request ends. An idle recipient starts a turn either way.
40
40
  group: The group chat it was written in (without the ``#``), or an empty string for a one-to-one message.
41
+ images: The pictures it carries, already checked: only a ``direct`` message has any.
41
42
  """
42
43
 
43
44
  sender: PeerInfo
@@ -47,6 +48,7 @@ class Incoming:
47
48
  direct: bool = False
48
49
  mode: str = MODE_STEER
49
50
  group: str = ""
51
+ images: tuple[ImageData, ...] = ()
50
52
 
51
53
 
52
54
  @dataclass(frozen=True, slots=True)
@@ -16,7 +16,8 @@ HTTP, for anyone who holds the token — a session, a script, a hook, ``curl``::
16
16
  the owner's token alone: ?a=&b= or ?group=) -> {"messages": [...]}
17
17
  GET /graph -> {"sessions", "groups", "edges", "recent"} (token only; for the gateway's graph)
18
18
  GET /older ?before=<message id>&limit= -> {"messages", "more"} the stored messages before one
19
- GET /timeline ?since=&until=&limit= -> {"messages", "span", "now"} stored messages, to replay on screen
19
+ GET /timeline ?since=&until=&limit= -> {"messages", "truncated", "span", "now"} stored messages, to replay on
20
+ screen (at most MAX_WINDOW; "truncated" says the stretch held more and its start is missing)
20
21
 
21
22
  A failure is an HTTP status and ``{"error": {"reason", "message"}}``; the reason is what a program acts on. A
22
23
  message's ``mode`` says what a busy recipient should do with it: ``steer`` (the default) hands it to the request
@@ -32,7 +33,7 @@ A *session* — ZettCode's, or any program that wants to be listed and reached
32
33
  {"op": "traffic", "kind": "message"|"graph", "message"?} to an observer: what passed, what changed
33
34
  {"op": "done", "n": 1, "handle": ...} the answer to a session or leave
34
35
  {"op": "refused", "n": 1, "error": {"reason", "message"}} ... or its refusal
35
- {"op": "deliver", "id": 9, "from": {...}, "text", "hops", "via", "mode"} a message; answer with an ack
36
+ {"op": "deliver", "id": 9, "from": {...}, "text", "hops", "via", "mode", "images"?} a message; answer with an ack
36
37
 
37
38
  and the session says::
38
39
 
@@ -68,6 +69,7 @@ characters the composer can type, a chain of messages that keep triggering one a
68
69
 
69
70
  from __future__ import annotations
70
71
 
72
+ import base64
71
73
  import re
72
74
  from collections.abc import Mapping
73
75
  from dataclasses import dataclass
@@ -76,15 +78,24 @@ from typing import Any
76
78
  from ..modes import MODE_STEER, MODES
77
79
  from .text import clean_text, clean_title
78
80
 
79
- #: Bumped whenever this protocol changes meaning. 8: group chats (``group`` on a send, the ``groups`` line, ``/groups``),
81
+ #: Bumped whenever this protocol changes meaning. 9: a one-to-one message may carry pictures (``images`` on a send and
82
+ #: on a ``deliver``, with a longer line for the ``deliver``); 8: group chats (``group`` on a send, the ``groups`` line,
83
+ #: ``/groups``),
80
84
  #: the persistent ``/history``, and observers (``observe``, the ``traffic`` line); 7: a message says whether a busy recipient should steer its running
81
85
  #: request with it or wait behind that request (``mode``, default steer); 6 was the HTTP server and its WebSocket per
82
86
  #: session, 5 lines of JSON on a TCP socket.
83
- PROTOCOL_VERSION = 8
87
+ PROTOCOL_VERSION = 9
84
88
 
85
89
  #: The longest message, in characters. Longer than anyone types, shorter than a paste of a file.
86
90
  MAX_TEXT = 32_000
87
91
 
92
+ #: The pictures a one-to-one chat message may carry: how many, how large each is once decoded, and how large they are
93
+ #: together. The broker keeps none of them: it hands them over and writes down only how many there were.
94
+ IMAGE_TYPES = ("image/png", "image/jpeg", "image/gif", "image/webp")
95
+ MAX_IMAGES = 4
96
+ MAX_IMAGE_BYTES = 4 << 20
97
+ MAX_IMAGES_BYTES = 8 << 20
98
+
88
99
  #: The longest title the roster keeps; a longer one is cut.
89
100
  MAX_TITLE = 120
90
101
 
@@ -95,6 +106,11 @@ MAX_WORKSPACE = 512
95
106
  #: character escaped) and a roster of a few hundred sessions both fit. A longer one is refused or ends the connection.
96
107
  MAX_BODY = 1 << 20
97
108
 
109
+ #: The longest ``POST /messages`` body, and the longest line a session reads from the broker: :data:`MAX_BODY` for the
110
+ #: words, plus the pictures a message may carry as base64 (a third larger than they are). Everything else a session sends
111
+ #: stays within :data:`MAX_BODY`.
112
+ MAX_MESSAGE_BODY = MAX_BODY + (MAX_IMAGES_BYTES + 2) // 3 * 4
113
+
98
114
  #: How many sessions a message may pass through. A reader who typed it is hop zero; each session whose agent
99
115
  #: forwards it adds one. Two agents that answer each other would otherwise spend tokens for ever, so the broker
100
116
  #: refuses the message that would be hop :data:`MAX_HOPS` + 1.
@@ -177,6 +193,75 @@ class Refused(Exception):
177
193
  return cls("invalid", "the other end refused without saying why")
178
194
 
179
195
 
196
+ @dataclass(frozen=True, slots=True)
197
+ class ImageData:
198
+ """One picture a message carries: its media type and the encoded file (not decoded pixels)."""
199
+
200
+ media_type: str
201
+ data: bytes
202
+
203
+ def to_wire(self) -> dict[str, str]:
204
+ """Return the picture as a line carries it: the media type and the file in base64."""
205
+ return {"media_type": self.media_type, "data": base64.b64encode(self.data).decode("ascii")}
206
+
207
+
208
+ def sniff_image(data: bytes) -> str | None:
209
+ """Return the media type a file's first bytes say it is, or ``None`` when it is not a picture a model takes."""
210
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
211
+ return "image/png"
212
+ if data.startswith(b"\xff\xd8\xff"):
213
+ return "image/jpeg"
214
+ if data.startswith((b"GIF87a", b"GIF89a")):
215
+ return "image/gif"
216
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
217
+ return "image/webp"
218
+ return None
219
+
220
+
221
+ def parse_images(raw: object) -> tuple[ImageData, ...]:
222
+ """Read the pictures a send carries, refusing what is not one.
223
+
224
+ A picture is ``{"media_type", "data"}`` with the file in base64. What it says it is must be what its first bytes say,
225
+ so a program cannot hand a model something else under an image's name, and each picture and all of them together
226
+ are bounded.
227
+
228
+ Raises:
229
+ Refused: ``invalid`` for a malformed or mislabelled picture, ``too_long`` for too many or too large.
230
+ """
231
+ if raw is None:
232
+ return ()
233
+ if not isinstance(raw, list):
234
+ raise Refused("invalid", "images is a list")
235
+ if len(raw) > MAX_IMAGES:
236
+ raise Refused("too_long", f"a message may carry at most {MAX_IMAGES} pictures")
237
+ pictures: list[ImageData] = []
238
+ total = 0
239
+ for item in raw:
240
+ if not isinstance(item, Mapping) or not isinstance(item.get("data"), str):
241
+ raise Refused("invalid", "a picture is {media_type, data}")
242
+ kind = item.get("media_type")
243
+ if kind not in IMAGE_TYPES:
244
+ raise Refused("invalid", f"a picture is one of: {', '.join(IMAGE_TYPES)}")
245
+ encoded: str = item["data"]
246
+ if len(encoded) > (MAX_IMAGE_BYTES + 2) // 3 * 4:
247
+ raise Refused("too_long", f"a picture is limited to {MAX_IMAGE_BYTES >> 20} MiB")
248
+ try:
249
+ data = base64.b64decode(encoded, validate=True)
250
+ except ValueError:
251
+ raise Refused("invalid", "a picture's data is not base64") from None
252
+ if not data:
253
+ raise Refused("invalid", "a picture is empty")
254
+ if len(data) > MAX_IMAGE_BYTES:
255
+ raise Refused("too_long", f"a picture is limited to {MAX_IMAGE_BYTES >> 20} MiB")
256
+ if sniff_image(data) != kind:
257
+ raise Refused("invalid", f"a picture said to be {kind} is not")
258
+ total += len(data)
259
+ if total > MAX_IMAGES_BYTES:
260
+ raise Refused("too_long", f"the pictures of a message are limited to {MAX_IMAGES_BYTES >> 20} MiB together")
261
+ pictures.append(ImageData(str(kind), data))
262
+ return tuple(pictures)
263
+
264
+
180
265
  def field(raw: Mapping[str, Any], name: str, kind: type, default: Any = None) -> Any:
181
266
  """Return a field of a line, or ``default`` when it is missing; a field of the wrong kind is ``invalid``."""
182
267
  value = raw.get(name, default)
@@ -319,19 +404,22 @@ class SessionUpdate:
319
404
  return cls(session_id, *_description(raw), _kind(raw), raw.get("direct") is True)
320
405
 
321
406
 
322
- def parse_send(raw: Mapping[str, Any], limits: Limits) -> tuple[str, str, str, int, str]:
407
+ def parse_send(
408
+ raw: Mapping[str, Any], limits: Limits, *, may_be_silent: bool = False
409
+ ) -> tuple[str, str, str, int, str]:
323
410
  """Read and check a ``send``: who it is for, its cleaned text, how it came, its hops, and its mode.
324
411
 
325
412
  The text is cleaned (see :func:`~.text.clean_text`) before it is measured, so the limit is on what would be
326
413
  delivered. A message a reader typed, or one from outside, starts a chain whatever hop count it claims. The mode
327
- is :data:`MODE_STEER` unless the sender asked for :data:`MODE_FOLLOWUP`.
414
+ is :data:`MODE_STEER` unless the sender asked for :data:`MODE_FOLLOWUP`. ``may_be_silent`` allows no words at all,
415
+ for a message that carries pictures.
328
416
 
329
417
  Raises:
330
418
  Refused: ``invalid`` for a field of the wrong kind or a mode the recipient does not know, ``empty`` for
331
419
  nothing to say, ``too_long`` past the limit, ``loop`` when the message would pass the hop limit.
332
420
  """
333
421
  to = field(raw, "to", str)
334
- text = clean_text(field(raw, "text", str))
422
+ text = clean_text(field(raw, "text", str, "" if may_be_silent else None))
335
423
  via = field(raw, "via", str, VIA_AGENT)
336
424
  hops = field(raw, "hops", int, 0)
337
425
  mode = field(raw, "mode", str, MODE_STEER)
@@ -341,7 +429,7 @@ def parse_send(raw: Mapping[str, Any], limits: Limits) -> tuple[str, str, str, i
341
429
  raise Refused("invalid", "mode is 'steer' or 'followup'")
342
430
  if hops < 0:
343
431
  raise Refused("invalid", "hops is a count")
344
- if not text.strip():
432
+ if not text.strip() and not may_be_silent:
345
433
  raise Refused("empty", "a message needs some text")
346
434
  if len(text) > limits.max_text:
347
435
  raise Refused("too_long", f"a message is limited to {limits.max_text} characters")