samagotchi 0.153.0 → 0.154.0

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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundles.md +9 -12
  8. data/docs/cli.md +80 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +3 -3
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +1 -1
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-push.md +1 -1
  25. data/docs/memory.md +2 -2
  26. data/docs/plugins.md +23 -28
  27. data/docs/sessions.md +24 -24
  28. data/docs/sub-agent.md +2 -2
  29. data/docs/system-one.md +1 -1
  30. data/docs/testing.md +16 -6
  31. data/lib/samagotchi/answer_text.rb +34 -0
  32. data/lib/samagotchi/bridge.rb +43 -32
  33. data/lib/samagotchi/bridge_client.rb +10 -9
  34. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  35. data/lib/samagotchi/broadcast/triage.rb +1 -1
  36. data/lib/samagotchi/broadcast_command.rb +5 -5
  37. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  38. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  39. data/lib/samagotchi/bundle_command.rb +2 -2
  40. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  41. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  42. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  44. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  45. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  46. data/lib/samagotchi/child_move.rb +3 -3
  47. data/lib/samagotchi/commands/registry.rb +6 -6
  48. data/lib/samagotchi/composer_route.rb +4 -5
  49. data/lib/samagotchi/config.rb +14 -12
  50. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  51. data/lib/samagotchi/context_candidates.rb +4 -3
  52. data/lib/samagotchi/continue_offer.rb +1 -1
  53. data/lib/samagotchi/debug_log.rb +1 -1
  54. data/lib/samagotchi/degrade.rb +75 -0
  55. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  56. data/lib/samagotchi/engine/cards.rb +87 -0
  57. data/lib/samagotchi/engine/context_judge.rb +138 -0
  58. data/lib/samagotchi/engine/events.rb +131 -0
  59. data/lib/samagotchi/engine/guardrails.rb +87 -0
  60. data/lib/samagotchi/engine/hooks.rb +119 -0
  61. data/lib/samagotchi/engine/messages.rb +124 -0
  62. data/lib/samagotchi/engine/model_settings.rb +127 -0
  63. data/lib/samagotchi/engine/models.rb +261 -0
  64. data/lib/samagotchi/engine/objectives.rb +116 -0
  65. data/lib/samagotchi/engine/participants.rb +86 -0
  66. data/lib/samagotchi/engine/plugins.rb +208 -0
  67. data/lib/samagotchi/engine/questions.rb +98 -0
  68. data/lib/samagotchi/engine/recap.rb +130 -0
  69. data/lib/samagotchi/engine/session.rb +131 -0
  70. data/lib/samagotchi/engine/side_requests.rb +135 -0
  71. data/lib/samagotchi/engine/steering.rb +134 -0
  72. data/lib/samagotchi/engine/toolkit.rb +269 -0
  73. data/lib/samagotchi/engine/turn_end.rb +180 -0
  74. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  75. data/lib/samagotchi/engine/verify.rb +153 -0
  76. data/lib/samagotchi/engine.rb +62 -2646
  77. data/lib/samagotchi/grants.rb +575 -0
  78. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  79. data/lib/samagotchi/guardrails/context.rb +2 -3
  80. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  81. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  82. data/lib/samagotchi/hook_routes.rb +3 -3
  83. data/lib/samagotchi/idle_recap.rb +4 -5
  84. data/lib/samagotchi/idle_reminders.rb +3 -4
  85. data/lib/samagotchi/iteration_limit.rb +2 -2
  86. data/lib/samagotchi/kernel_loop.rb +10 -11
  87. data/lib/samagotchi/launch_mode.rb +13 -32
  88. data/lib/samagotchi/level_setting_command.rb +3 -2
  89. data/lib/samagotchi/llm_context_command.rb +3 -2
  90. data/lib/samagotchi/log.rb +7 -7
  91. data/lib/samagotchi/log_line.rb +1 -1
  92. data/lib/samagotchi/log_path.rb +1 -1
  93. data/lib/samagotchi/note_command.rb +1 -1
  94. data/lib/samagotchi/note_delivery.rb +18 -8
  95. data/lib/samagotchi/objectives.rb +24 -3
  96. data/lib/samagotchi/objectives_check.rb +8 -27
  97. data/lib/samagotchi/objectives_command.rb +4 -69
  98. data/lib/samagotchi/objectives_edit.rb +87 -0
  99. data/lib/samagotchi/output_formatter.rb +7 -1
  100. data/lib/samagotchi/owner_lock.rb +7 -1
  101. data/lib/samagotchi/parent_answers_command.rb +5 -4
  102. data/lib/samagotchi/participant_exposure.rb +42 -0
  103. data/lib/samagotchi/participant_mode.rb +1 -1
  104. data/lib/samagotchi/participant_name.rb +1 -1
  105. data/lib/samagotchi/participants.rb +17 -17
  106. data/lib/samagotchi/participants_watch.rb +1 -1
  107. data/lib/samagotchi/pending_input_queue.rb +2 -2
  108. data/lib/samagotchi/plugin/api.rb +3 -2
  109. data/lib/samagotchi/plugin/context.rb +21 -7
  110. data/lib/samagotchi/plugin/peer.rb +2 -1
  111. data/lib/samagotchi/plugin/sessions.rb +6 -4
  112. data/lib/samagotchi/plugin_tasks.rb +1 -2
  113. data/lib/samagotchi/private_files.rb +29 -0
  114. data/lib/samagotchi/project_env.rb +2 -2
  115. data/lib/samagotchi/question_desk.rb +8 -7
  116. data/lib/samagotchi/recap_command.rb +2 -1
  117. data/lib/samagotchi/recap_setup.rb +1 -1
  118. data/lib/samagotchi/recap_store.rb +1 -1
  119. data/lib/samagotchi/redact.rb +86 -0
  120. data/lib/samagotchi/reminder_queue.rb +2 -2
  121. data/lib/samagotchi/remote_attach_command.rb +1 -1
  122. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  123. data/lib/samagotchi/review_start.rb +1 -1
  124. data/lib/samagotchi/run_options.rb +6 -6
  125. data/lib/samagotchi/self_report.rb +42 -39
  126. data/lib/samagotchi/send_command/busy.rb +1 -1
  127. data/lib/samagotchi/send_command.rb +4 -1
  128. data/lib/samagotchi/session.rb +11 -2
  129. data/lib/samagotchi/session_access/contract.rb +3 -3
  130. data/lib/samagotchi/session_access/local.rb +9 -9
  131. data/lib/samagotchi/session_access/remote.rb +17 -7
  132. data/lib/samagotchi/session_access.rb +4 -4
  133. data/lib/samagotchi/session_archive_command.rb +2 -2
  134. data/lib/samagotchi/session_chain.rb +8 -6
  135. data/lib/samagotchi/session_commands.rb +45 -48
  136. data/lib/samagotchi/session_continue.rb +1 -1
  137. data/lib/samagotchi/session_delete_command.rb +4 -4
  138. data/lib/samagotchi/session_manager.rb +59 -36
  139. data/lib/samagotchi/session_metrics.rb +2 -2
  140. data/lib/samagotchi/session_model_settings.rb +3 -2
  141. data/lib/samagotchi/session_participants.rb +1 -1
  142. data/lib/samagotchi/session_retention.rb +3 -3
  143. data/lib/samagotchi/sessions_command.rb +4 -4
  144. data/lib/samagotchi/share_command.rb +10 -10
  145. data/lib/samagotchi/steer.rb +1 -1
  146. data/lib/samagotchi/system_prompt.rb +102 -109
  147. data/lib/samagotchi/task_watch.rb +1 -1
  148. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  149. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  150. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  151. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  152. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  153. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  154. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  155. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  156. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  157. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  158. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  159. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  160. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  161. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  163. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  164. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  165. data/lib/samagotchi/terminal_ui.rb +142 -1387
  166. data/lib/samagotchi/title_command.rb +2 -2
  167. data/lib/samagotchi/tool_runner.rb +14 -16
  168. data/lib/samagotchi/toolkit_command.rb +2 -1
  169. data/lib/samagotchi/tools/delegate.rb +3 -1
  170. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  171. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  172. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  173. data/lib/samagotchi/tools/send_note.rb +2 -1
  174. data/lib/samagotchi/turn_endings.rb +2 -2
  175. data/lib/samagotchi/turn_flow.rb +4 -5
  176. data/lib/samagotchi/update_hint.rb +2 -2
  177. data/lib/samagotchi/version.rb +1 -1
  178. data/lib/samagotchi/web/app.rb +39 -50
  179. data/lib/samagotchi/web/participant_access.rb +2 -2
  180. data/lib/samagotchi/web/participant_review.rb +5 -5
  181. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  182. data/lib/samagotchi/web/participant_view.rb +2 -82
  183. data/lib/samagotchi/web/participants_admin.rb +1 -1
  184. data/lib/samagotchi/web/public/block_host.js +35 -8
  185. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  186. data/lib/samagotchi/web/public/chains.js +2 -2
  187. data/lib/samagotchi/web/public/claim_link.js +4 -4
  188. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  189. data/lib/samagotchi/web/public/format.js +3 -3
  190. data/lib/samagotchi/web/push/store.rb +1 -1
  191. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  192. data/lib/samagotchi/web/session_control.rb +12 -8
  193. data/lib/samagotchi/web/session_create.rb +1 -1
  194. data/lib/samagotchi/web/session_hub.rb +2 -2
  195. data/lib/samagotchi/web/session_show.rb +2 -2
  196. data/lib/samagotchi/web/session_summary.rb +1 -1
  197. data/lib/samagotchi/worker.rb +100 -9
  198. data/lib/samagotchi/worker_link.rb +3 -3
  199. data/lib/samagotchi/working_set_command.rb +3 -2
  200. metadata +29 -6
  201. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  202. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  203. data/lib/samagotchi/web/grants.rb +0 -575
  204. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  205. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "degrade"
4
5
  require_relative "model_profile"
5
6
  require_relative "served_model"
6
7
  require_relative "prompt_note"
@@ -31,7 +32,7 @@ module Samagotchi
31
32
  # load budget (spec/samagotchi/testing/sandbox_spec.rb).
32
33
  autoload :ObjectivesCommand, File.expand_path("objectives_command", __dir__)
33
34
 
34
- # The session commands a REPL and a session worker both run: /model,
35
+ # The session commands a session worker runs (a -p one-shot too): /model,
35
36
  # /models, /llm-context, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
36
37
  # the Engine and its TurnFlow; the host prints the result's output and
37
38
  # runs a continue turn when asked to (#run never runs a turn).
@@ -159,24 +160,24 @@ module Samagotchi
159
160
  registry.register("/quit", "leave, like /exit", id: :exit, local: true, match: exit_match("/quit"))
160
161
  registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true,
161
162
  match: ->(text) { text.casecmp?("/archive") })
162
- # The REPL owns its session: it answers /detach with a note, and doesn't offer it.
163
+ # The terminal's: the web has no worker to leave running.
163
164
  registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
164
165
  match: ->(text) { text.casecmp?("/detach") })
165
166
  # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
166
167
  # themselves (ComposerRoute.steer_choice) before any command check; the
167
168
  # entries exist for Tab completion and /help. The web composer sends
168
- # /cut too (its queue is Alt+Enter). Both terminals queue: the REPL in
169
- # its own list, the attached TUI's worker in its input files.
169
+ # /cut too (its queue is Alt+Enter). The attached TUI queues in its
170
+ # worker's input files.
170
171
  registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
171
- local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
172
+ local: true, uis: %i[attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
172
173
  registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
173
- local: true, uis: %i[repl attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
174
+ local: true, uis: [:attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
174
175
  # Takes back the newest message this attached TUI queued, before it
175
176
  # runs (the worker's POST turn/dequeue); the web has × on the bubble.
176
177
  registry.register("/unqueue", "remove your newest queued message before it runs", local: true, uis: [:attached],
177
178
  match: ->(text) { text.casecmp?("/unqueue") })
178
179
  # chi share for the attached session (TerminalUI::ParticipantCommands):
179
- # a participant's turns run on its worker, so the REPL points there.
180
+ # a participant's turns run on its worker.
180
181
  registry.register("/share", "invite someone to review this session (/share NAME [--agent] [--expires 3d] [--cap 2])",
181
182
  local: true, uis: [:attached], match: ->(text) { text.match?(%r{\A/share(?:\s.*)?\z}i) })
182
183
  registry.register("/participants", "list this session's participants (revoke [P], cap P DOLLARS)",
@@ -286,15 +287,15 @@ module Samagotchi
286
287
  "commands:\n#{lines.join("\n")}"
287
288
  end
288
289
 
289
- # Where a UI's own entry runs, for /help: both terminals are
290
- # "terminal" (also for no uis: every terminal), then "web".
290
+ # Where a UI's own entry runs, for /help: the attached TUI is
291
+ # "terminal" (also for no uis: the terminal), then "web". An older
292
+ # worker's :repl is no UI any more.
291
293
  # @param uis [Array<Symbol>, nil]
292
- # @return [String] "terminal", "repl", "terminal and web" …
294
+ # @return [String] "terminal", "web", "terminal and web"
293
295
  def ui_words(uis)
294
296
  return "terminal" if uis.nil?
295
297
 
296
- terminals = uis & %i[repl attached]
297
- words = terminals.size == 2 ? ["terminal"] : terminals.map(&:to_s)
298
+ words = uis.include?(:attached) ? ["terminal"] : []
298
299
  words << "web" if uis.include?(:web)
299
300
  words.join(" and ")
300
301
  end
@@ -309,8 +310,8 @@ module Samagotchi
309
310
  # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
310
311
  # a /continue yes is held to guardrails.parent_answers, as chi
311
312
  # answer --option Continue is (Guardrails::ParentPolicy)
312
- # @param user_ui [Boolean] a user at a UI sent it (the REPL, an attached
313
- # TUI, the web; not a piped or marked one): only such a line loosens
313
+ # @param user_ui [Boolean] a user at a UI sent it (an attached TUI, the
314
+ # web; not a piped or marked one): only such a line loosens
314
315
  # /parent-answers
315
316
  # @return [Result, nil] nil when +line+ is not one of these commands
316
317
  def run(line, confirm: true, as_parent: false, user_ui: false)
@@ -427,7 +428,7 @@ module Samagotchi
427
428
  end
428
429
 
429
430
  # The output goes into the conversation for the next turn. Not saved
430
- # here (as in the REPL, the next turn saves it). No parent-session
431
+ # here (the next turn saves it). No parent-session
431
432
  # marker: the user runs this, not the model. A worker's `chi --model X`
432
433
  # default is unset, though: it was that run's, not the default of a chi
433
434
  # run here (Tools::Builtins.cli_model_unset). It runs in the session's
@@ -726,7 +727,7 @@ module Samagotchi
726
727
 
727
728
  # /recap on|off|default (RecapCommand); :recap in changed when this
728
729
  # session's own recap.enabled moved. It doesn't save: its host does on
729
- # a changed result (Worker#run_command, the REPL).
730
+ # a changed result (Worker#run_command).
730
731
  def recap_setting(text)
731
732
  output, changed, status = RecapCommand.new(engine: @engine).run(text.delete_prefix(RECAP_COMMAND))
732
733
  reply(output, status: status, changed: changed ? [:recap] : [])
@@ -884,65 +885,61 @@ module Samagotchi
884
885
  return unless session
885
886
 
886
887
  @engine.store_model!(session)
887
- begin
888
- @save.call(session)
889
- rescue StandardError
890
- nil
891
- end
888
+ Degrade.call(:model, "model_switch_save_failed") { @save.call(session) }
892
889
  end
893
890
 
894
891
  # "; served: <name>" when the server serves another model than asked
895
892
  # and its host's served: doesn't expect it.
896
893
  def served_note
897
- served, asked = @engine.served_model
898
- ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
899
- rescue StandardError
900
- ""
894
+ Degrade.call(:model, "served_note_failed", fallback: "") do
895
+ served, asked = @engine.served_model
896
+ ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
897
+ end
901
898
  end
902
899
 
903
900
  # "; sampling: temperature=0.6 (hosts.work)" when the model has any.
904
901
  def sampling_note
905
- summary = @engine.sampling_summary
906
- summary ? "; sampling: #{summary}" : ""
907
- rescue StandardError
908
- ""
902
+ Degrade.call(:model, "sampling_note_failed", fallback: "") do
903
+ summary = @engine.sampling_summary
904
+ summary ? "; sampling: #{summary}" : ""
905
+ end
909
906
  end
910
907
 
911
908
  # "; steer cut: after 60 s (models: qwen)", or "; steer cut: never
912
909
  # (hosts.splash)" for 0 or less, when the model has one configured;
913
910
  # "" for the default.
914
911
  def steer_cut_after_note
915
- summary = @engine.steer_cut_summary
916
- summary ? "; steer cut: #{summary}" : ""
917
- rescue StandardError
918
- ""
912
+ Degrade.call(:model, "steer_cut_note_failed", fallback: "") do
913
+ summary = @engine.steer_cut_summary
914
+ summary ? "; steer cut: #{summary}" : ""
915
+ end
919
916
  end
920
917
 
921
918
  # "; notes: model_notes_deepseek (system, 612 chars)": the model notes
922
919
  # the session's prompt carries (after a switch, the ones the new
923
920
  # model's prompt loads); "" without any.
924
921
  def notes_note
925
- text = PromptNote.text(@engine.prompt_notes)
926
- text.empty? ? "" : "; notes: #{text}"
927
- rescue StandardError
928
- ""
922
+ Degrade.call(:model, "notes_note_failed", fallback: "") do
923
+ text = PromptNote.text(@engine.prompt_notes)
924
+ text.empty? ? "" : "; notes: #{text}"
925
+ end
929
926
  end
930
927
 
931
928
  # "; thinking: off (models: qwen)" when the model has a level set.
932
929
  def thinking_note
933
- summary = @engine.thinking_summary
934
- summary ? "; thinking: #{summary}" : ""
935
- rescue StandardError
936
- ""
930
+ Degrade.call(:model, "thinking_note_failed", fallback: "") do
931
+ summary = @engine.thinking_summary
932
+ summary ? "; thinking: #{summary}" : ""
933
+ end
937
934
  end
938
935
 
939
936
  # "; reasoning history: drop (models: qwen)" when the chat model has a
940
937
  # level set.
941
938
  def reasoning_history_note
942
- summary = @engine.reasoning_history_summary
943
- summary ? "; reasoning history: #{summary}" : ""
944
- rescue StandardError
945
- ""
939
+ Degrade.call(:model, "reasoning_history_note_failed", fallback: "") do
940
+ summary = @engine.reasoning_history_summary
941
+ summary ? "; reasoning history: #{summary}" : ""
942
+ end
946
943
  end
947
944
 
948
945
  # " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
@@ -954,9 +951,9 @@ module Samagotchi
954
951
  end
955
952
 
956
953
  def chat_model?
957
- @engine.chat_model?
958
- rescue StandardError
959
- false
954
+ Degrade.call(:model, "chat_model_unresolved", fallback: false) do
955
+ @engine.chat_model?
956
+ end
960
957
  end
961
958
 
962
959
  # "profile=qwen36, server (chat_template)": the profile and where it came from.
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # A continue that can't happen (#run). reason: :continued
25
25
  # (another session continues it already: +ids+ names it), :open_children
26
26
  # (open children it can't move to the new link: on an older chi's
27
- # worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
27
+ # worker or open in a one-turn chi run; +ids+ names them) or :folder_gone (its
28
28
  # folder isn't there).
29
29
  class Refused < StandardError
30
30
  attr_reader :session_id, :reason, :ids
@@ -9,7 +9,7 @@ require_relative "session_access/local"
9
9
  module Samagotchi
10
10
  # `chi sessions delete`: remove sessions for good (SessionManager.delete_session),
11
11
  # one line per id. A live worker's session is refused unless --force stops
12
- # the worker first; a chi REPL's never is.
12
+ # the worker first; a one-turn chi run's never is.
13
13
  class SessionDeleteCommand
14
14
  include CLI::Command
15
15
 
@@ -18,8 +18,8 @@ module Samagotchi
18
18
  Deletes each session: its history, notes, images and queued input.
19
19
  This can't be undone.
20
20
  -f, --force stop a session's running worker first (without it,
21
- a live session is refused); a session open in a
22
- chi REPL is always refused
21
+ a live session is refused); a session in a one-turn
22
+ chi run (chi --non-interactive) is always refused
23
23
  TEXT
24
24
  PREVIEW_LIMIT = 60
25
25
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) { |f| f.switch "-f", "--force" }
@@ -67,7 +67,7 @@ module Samagotchi
67
67
  @stdout.puts(["#{short} deleted#{" (stopped its worker)" if result[:stopped]}", preview].compact.join(" "))
68
68
  true
69
69
  rescue SessionManager::OwnedByTUI
70
- @stdout.puts("#{short} refused: it is open in a chi REPL; close it there first")
70
+ @stdout.puts("#{short} refused: it is #{OwnerLock::TUI_BUSY}; let it finish first")
71
71
  false
72
72
  rescue SessionManager::DeleteRefused => e
73
73
  line = if e.reason == :still_stopping then "its worker is still shutting down; try again in a moment"
@@ -81,23 +81,32 @@ module Samagotchi
81
81
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
82
82
  # session_continue requires this file.
83
83
  autoload :SessionContinue, File.expand_path("session_continue", __dir__)
84
- # Needed only on an archive or a delete (it loads Web::Grants).
84
+ # Needed only on an archive or a delete (it loads Grants).
85
85
  autoload :Participants, File.expand_path("participants", __dir__)
86
86
 
87
87
  class SessionManager
88
- # Raised when the interactive TUI owns the session: it runs its own Engine
89
- # and reads no input files, so a worker must not be spawned or signalled.
88
+ # Raised when a terminal's own chi owns the session (OwnerLock kind
89
+ # "tui": a -p --non-interactive one-shot, TerminalUI): it runs its own
90
+ # Engine and reads no input files, so a worker must not be spawned or
91
+ # signalled.
90
92
  class OwnedByTUI < StandardError
91
- # @return [String] the session the REPL owns (a delegate's, when an
92
- # archive met one)
93
+ # @return [String] the session it owns (a delegate's, when an archive
94
+ # met one)
93
95
  attr_reader :session_id
94
96
 
95
- def initialize(session_id)
97
+ def initialize(session_id, words = nil)
96
98
  @session_id = session_id
97
- super("session #{session_id} is open in a chi REPL; close it there first")
99
+ super(words || "session #{session_id} is #{OwnerLock::TUI_BUSY}; let it finish first")
98
100
  end
99
101
  end
100
102
 
103
+ # A `chi scratch` session: its terminal's alone (Session::SCRATCH_REFUSED).
104
+ # An OwnedByTUI, so every caller that refuses a terminal's session
105
+ # (chi send, chi web's 409 owned_by_tui) refuses it the same way.
106
+ class ScratchSession < OwnedByTUI
107
+ def initialize(session_id) = super(session_id, Session::SCRATCH_REFUSED)
108
+ end
109
+
101
110
  # A delete that would pull the session from under its live worker.
102
111
  # reason: :worker_running (not asked to stop it) or :still_stopping
103
112
  # (stopped, but the worker outlived the wait).
@@ -173,12 +182,15 @@ module Samagotchi
173
182
  # requester, rules and claim (VerifyFresh.start)
174
183
  # @param before_spawn [#call, nil] (session) runs once the session and
175
184
  # its note are saved, before its worker spawns
185
+ # @param scratch [Boolean] a `chi scratch` session (its terminal's
186
+ # AttachLauncher): its worker runs a scratch Engine and deletes it
187
+ # once its last client is gone
176
188
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
177
189
  # and #model_warning: an id its host's saved list doesn't have
178
190
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
179
191
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
180
192
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
181
- note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil)
193
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
182
194
  sd = state_dir || Session.default_state_dir
183
195
  # The resolved ref is stored (a resumed session keeps its model when an
184
196
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -201,7 +213,8 @@ module Samagotchi
201
213
  delegate: delegate,
202
214
  setup: setup,
203
215
  continues: continues,
204
- review: review
216
+ review: review,
217
+ scratch: scratch
205
218
  )
206
219
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
207
220
  session.toolkit_origin = setup.toolkit_origin || "default"
@@ -316,7 +329,8 @@ module Samagotchi
316
329
  # +project_root+ unless asked for every project.
317
330
  # @param live [Boolean] only sessions a worker owns now (the owner lock,
318
331
  # not the saved status, which a dead worker leaves at "running"); a
319
- # REPL-owned session is left out: it can't take notes
332
+ # session a one-shot (or an older chi's REPL) owns is left out: it
333
+ # can't take notes
320
334
  # @param cwd [String, nil] only sessions in this folder or below it
321
335
  # @param project_root [String, nil] only this project's sessions
322
336
  # (Session#project_root)
@@ -405,7 +419,7 @@ module Samagotchi
405
419
  # stopped first; the marker is written even while it shuts down.
406
420
  # @return [ArchiveResult]
407
421
  # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
408
- # @raise [OwnedByTUI] a chi REPL owns it or one of its children
422
+ # @raise [OwnedByTUI] a one-turn chi run owns it or one of its children
409
423
  # @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
410
424
  # queued in one (a later resume would run it), a live one offers to
411
425
  # continue past the step limit, or it is a scratch session
@@ -447,7 +461,7 @@ module Samagotchi
447
461
  # turn runs), :queued (input/ holds a prompt, which outlives the stop and
448
462
  # runs on a resume, live worker or not), :offer (a live worker's continue
449
463
  # offer, which the stop would drop), or nil.
450
- # @raise [OwnedByTUI] a chi REPL owns it
464
+ # @raise [OwnedByTUI] a one-turn chi run owns it
451
465
  def self.archive_refusal(sid, owner, state_dir)
452
466
  raise OwnedByTUI, sid if owner&.tui?
453
467
 
@@ -555,6 +569,8 @@ module Samagotchi
555
569
  def self.resume_session(session_id, state_dir: nil)
556
570
  sd = state_dir || Session.default_state_dir
557
571
  session = Session.load(session_id, state_dir: sd)
572
+ # Its terminal's worker came from spawn_session; nothing wakes another.
573
+ raise ScratchSession, session.id if session.scratch
558
574
  return session if refuse_tui!(session.id, state_dir: sd)
559
575
 
560
576
  # status is turn state, not liveness: a new worker runs no turn yet,
@@ -576,7 +592,7 @@ module Samagotchi
576
592
 
577
593
  # A delegate child rang a parent that has no worker (ChildRing): start
578
594
  # one, which runs a turn for the report. Not for a parent that is gone,
579
- # stopped, archived, a scratch session, open in a chi REPL, or a
595
+ # stopped, archived, a scratch session, a one-turn run's, or a
580
596
  # continue's new link still taking its delegates (its own spawn comes):
581
597
  # the ring waits on disk for whoever runs it next (#resume_session
582
598
  # itself would clear the stop). A stop landing between the check and
@@ -686,7 +702,8 @@ module Samagotchi
686
702
  reminders: "it has reminders, which its worker's older chi can't carry over a restart",
687
703
  command_running: "a /btw (or another command) is still running",
688
704
  relays_open: "a delegate's approval relayed here is open or just answered",
689
- background_tasks: "background tasks it started are still running"
705
+ background_tasks: "background tasks it started are still running",
706
+ scratch: "a chi scratch session's worker ends with its terminal"
690
707
  }.merge(WorkerIdleExit::LEAVING_WORDS).freeze
691
708
 
692
709
  # What the CLI and the web say.
@@ -726,7 +743,7 @@ module Samagotchi
726
743
  # be lost and starts its successor. If the worker cannot be asked, stop
727
744
  # it without a stop marker and resume only when disk shows no work to lose.
728
745
  # @return [Restarted]
729
- # @raise [OwnedByTUI] a chi REPL owns the session
746
+ # @raise [OwnedByTUI] a one-turn chi run owns the session
730
747
  # @raise [RestartRefused]
731
748
  # @raise [ArgumentError] no such session
732
749
  def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: ClientId::CLI_RESTART)
@@ -917,7 +934,7 @@ module Samagotchi
917
934
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
918
935
 
919
936
  # session.keep_empty off: sessions left with nothing in them are deleted
920
- # (the worker as it exits, the TUI at /exit, the retention sweep).
937
+ # (the worker as it exits, the retention sweep).
921
938
  def self.discard_empty?
922
939
  Samagotchi::Config.get("session.keep_empty") != true
923
940
  rescue StandardError
@@ -926,25 +943,14 @@ module Samagotchi
926
943
 
927
944
  # Whether a session being left (or found left) is deleted: session.keep_empty
928
945
  # is off and nothing happened in it (#empty_session?). The worker as it
929
- # leaves, the REPL at /exit and the retention sweep all ask this.
946
+ # leaves and the retention sweep both ask this.
930
947
  # @param default_model [String, nil] what a new session starts on
931
- # @param model_name [String, nil] the model its owner runs now (the REPL
932
- # keeps /model in its Engine); the saved one is checked either way
933
948
  # @param used_memory_names [Array<String>] memory its owner's Engine used
934
949
  # before any save
935
- # @param unsaved [Session, nil] the REPL's working copy, judged instead
936
- # when the session was never saved
937
- def self.discardable?(session_id, default_model:, state_dir: nil, model_name: default_model, used_memory_names: [],
938
- unsaved: nil)
939
- return false unless discard_empty? && same_model?(model_name, default_model) && Array(used_memory_names).empty?
940
-
941
- sd = state_dir || Session.default_state_dir
942
- if unsaved && !File.exist?(Session.session_file(session_id, state_dir: sd))
943
- return no_conversation?(unsaved.messages) && unsaved.last_prompt.to_s.strip.empty? &&
944
- empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
945
- end
950
+ def self.discardable?(session_id, default_model:, state_dir: nil, used_memory_names: [])
951
+ return false unless discard_empty? && Array(used_memory_names).empty?
946
952
 
947
- empty_session?(session_id, state_dir: sd, default_model: default_model)
953
+ empty_session?(session_id, state_dir: state_dir || Session.default_state_dir, default_model: default_model)
948
954
  end
949
955
 
950
956
  # A session nothing happened in: no conversation, no turn tried (a failed
@@ -976,7 +982,7 @@ module Samagotchi
976
982
  false
977
983
  end
978
984
 
979
- # Only the system prompt (the REPL seeds one, a saved session keeps
985
+ # Only the system prompt (an Engine seeds one, a saved session keeps
980
986
  # it); a context note is a system message too, but with its kind.
981
987
  # A turn note (the tail system message after a failed, cancelled or empty
982
988
  # turn) is not a conversation either: a session whose only turn failed
@@ -1049,7 +1055,7 @@ module Samagotchi
1049
1055
  # @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
1050
1056
  # @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
1051
1057
  # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
1052
- # @raise [OwnedByTUI] a chi REPL owns it (never stopped from here)
1058
+ # @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
1053
1059
  # @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
1054
1060
  # outlived the wait
1055
1061
  # @param purge_runs [Boolean] also delete System 1's saved runs of it and
@@ -1130,7 +1136,7 @@ module Samagotchi
1130
1136
  # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
1131
1137
  # A stopped session's worker exits 0 (a signalled one too) and a
1132
1138
  # crashed one 1, there.
1133
- # @return [Symbol] :idle_exit, :exit_requested or :restart
1139
+ # @return [Symbol] :idle_exit, :exit_requested, :restart or :scratch_left
1134
1140
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
1135
1141
  idle_exit_minutes: nil, poll_interval: nil)
1136
1142
  # A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
@@ -1175,6 +1181,13 @@ module Samagotchi
1175
1181
  signals.restore!
1176
1182
  end
1177
1183
  Log.info(:worker, "stop", reason: result)
1184
+ # A scratch session goes with its worker however it leaves (its
1185
+ # terminal deletes it too, whichever comes first); nothing resumes it.
1186
+ if worker.scratch?
1187
+ delete_left_scratch(session_id, state_dir: sd)
1188
+ exit(result == :crashed ? 1 : 0) if %i[stopped signal crashed].include?(result)
1189
+ return result
1190
+ end
1178
1191
  # A stopped session is left as it is: no resume for input that came
1179
1192
  # in, no discard. One whose worker a signal stopped isn't discarded
1180
1193
  # either; input that came in (queued behind the turn it stopped)
@@ -1231,6 +1244,16 @@ module Samagotchi
1231
1244
  Process.exit!(0)
1232
1245
  end
1233
1246
 
1247
+ # Delete a scratch session its worker left (its terminal gone:
1248
+ # Worker#leave_scratch, or any other way out), now the lock is free.
1249
+ # Its terminal may have deleted it first.
1250
+ private_class_method def self.delete_left_scratch(session_id, state_dir:)
1251
+ delete_session(session_id, state_dir: state_dir)
1252
+ Log.info(:worker, "scratch_deleted", sid: session_id)
1253
+ rescue DeleteRefused, ArgumentError, SystemCallError => e
1254
+ Log.info(:worker, "scratch_kept", sid: session_id, error: e.class.name, msg: e.message)
1255
+ end
1256
+
1234
1257
  # Delete a session its worker left empty. Checked again now the lock is
1235
1258
  # free: a note or input may have come in since the worker looked, and a
1236
1259
  # worker woken meanwhile owns it (delete_session refuses). A `chi send`
@@ -1477,7 +1500,7 @@ module Samagotchi
1477
1500
  # a worker that doesn't queue (no file then: it would steer), :timeout
1478
1501
  # when it took the request but never answered (no file then), or
1479
1502
  # :failed when the input file couldn't be written
1480
- # @raise [OwnedByTUI] a chi REPL owns the session
1503
+ # @raise [OwnedByTUI] a one-turn chi run owns the session
1481
1504
  # @raise [ArgumentError] no such session
1482
1505
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1483
1506
  author: nil, pending_limit: nil, anchors: nil, state_dir: nil, manager: self, bridge: nil)
@@ -1532,7 +1555,7 @@ module Samagotchi
1532
1555
  # A participant's file only for a worker started after it: a worker
1533
1556
  # still holding the session with its Bridge gone (exiting) may be an
1534
1557
  # older one, which would run it without the review boundary.
1535
- # (A chi REPL holding it is refused as for anyone, below.)
1558
+ # (A one-turn chi run holding it is refused as for anyone, below.)
1536
1559
  if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
1537
1560
  return DeliverResult.new(status: :timeout, via: :file,
1538
1561
  ack: { "error" => "worker_timeout",
@@ -25,8 +25,8 @@ module Samagotchi
25
25
  # SessionMetrics is a persistent, error-isolated collector of per-session
26
26
  # analytics. It implements the same #call(event) sink contract as
27
27
  # SessionObserver subscribers, so Engine#run_turn feeds it (it forwards every
28
- # event to its SessionObserver): the REPL, the -p/--non-interactive/--resume
29
- # paths and SessionManager background workers.
28
+ # event to its SessionObserver): a --non-interactive one-shot and the
29
+ # session workers.
30
30
  #
31
31
  # Each finished turn leaves a record (status, timings, model, prompt and
32
32
  # completion tokens, tool calls, iterations, retries, cuts); session totals are
@@ -7,6 +7,7 @@ require_relative "session_setting"
7
7
  require_relative "steer_cut"
8
8
  require_relative "steer_cut_after"
9
9
  require_relative "thinking"
10
+ require_relative "session"
10
11
 
11
12
  module Samagotchi
12
13
  # The session's settings on its effective model, as the Engine reads them:
@@ -163,7 +164,7 @@ module Samagotchi
163
164
  # @raise [ArgumentError] with no session yet
164
165
  def reasoning_history_override=(level)
165
166
  session = @session.call
166
- raise ArgumentError, "no session yet" unless session
167
+ raise ArgumentError, Session::NONE_YET unless session
167
168
 
168
169
  SessionSetting::REASONING_HISTORY.set(session, level)
169
170
  end
@@ -178,7 +179,7 @@ module Samagotchi
178
179
  # @raise [ArgumentError] with no session yet
179
180
  def thinking_override=(level)
180
181
  session = @session.call
181
- raise ArgumentError, "no session yet" unless session
182
+ raise ArgumentError, Session::NONE_YET unless session
182
183
 
183
184
  SessionSetting::THINKING.set(session, level)
184
185
  end
@@ -4,7 +4,7 @@ require_relative "steer"
4
4
  require_relative "participant_mode"
5
5
 
6
6
  module Samagotchi
7
- # Loaded only when a session is read: it brings Web::Grants (and Rack).
7
+ # Loaded only when a session is read: it brings Grants.
8
8
  autoload :Participants, File.expand_path("participants", __dir__)
9
9
 
10
10
  # What a session's Engine knows of its participants: participants.json
@@ -30,7 +30,7 @@ module Samagotchi
30
30
  DEFAULT_KEEP_STATUS = [].freeze
31
31
 
32
32
  # How long a session may sit empty before the sweep takes it: its
33
- # worker (or a REPL) deletes it as it leaves, so the sweep only catches
33
+ # worker deletes it as it leaves, so the sweep only catches
34
34
  # those killed first (a reboot, kill -9).
35
35
  EMPTY_GRACE_SECONDS = 3600
36
36
 
@@ -69,7 +69,7 @@ module Samagotchi
69
69
  end
70
70
 
71
71
  # Directories with no session file and nothing but the skeleton, nobody
72
- # owning them: a REPL killed before its first save, or a worker woken
72
+ # owning them: a one-shot killed before its first save, or a worker woken
73
73
  # just as its session was discarded.
74
74
  # @return [Array<String>] their ids
75
75
  private_class_method def self.prune_orphan_dirs(state_dir, dry_run:)
@@ -161,7 +161,7 @@ module Samagotchi
161
161
  # +any_age+ makes every session eligible, whatever its age and the
162
162
  # count (`chi sessions clean` with no --days: test runs are throwaway).
163
163
  #
164
- # A scratch session nobody owns (its REPL was killed) goes whatever its
164
+ # A scratch session nobody owns (its terminal and worker were killed) goes whatever its
165
165
  # age, its status and the count; +test_only+ takes it too.
166
166
  #
167
167
  # +max_count+ counts the sessions that stay: one deleted whatever its
@@ -21,7 +21,7 @@ module Samagotchi
21
21
  # `chi sessions`: list, stop, restart, archive/unarchive, delete, prune,
22
22
  # clean and stats sessions from the shell (bin/chi dispatches here before
23
23
  # OptionParser; the subcommands have their own flags). Not SessionCommands,
24
- # the REPL's slash commands for sessions (session_commands.rb).
24
+ # the slash commands a session's worker runs (session_commands.rb).
25
25
  class SessionsCommand
26
26
  include CLI::Command
27
27
 
@@ -207,15 +207,15 @@ module Samagotchi
207
207
  )
208
208
  case format
209
209
  when "json"
210
- # owner: "worker", "tui" (a chi REPL: it takes no notes or messages)
210
+ # owner: "worker", "tui" (a one-turn chi run: it takes no notes or messages)
211
211
  # or nil; recap: the saved recap's first sentence, or nil; project:
212
212
  # its git project's root, or nil
213
213
  # parent_id: the session that delegated it (the delegate tool), or nil
214
214
  # delegate: started by the delegate tool (a fork has a parent_id too)
215
215
  # continues: the session it continues (its chain's previous link), or nil
216
216
  # archived: hidden from the lists (only with --archived can it be true)
217
- # scratch: a `chi scratch` session (deleted when its REPL ends, a
218
- # leftover one at the next sweep)
217
+ # scratch: a `chi scratch` session (deleted when its terminal leaves,
218
+ # a leftover one at the next sweep)
219
219
  # ctx_pct: how full the context was after the last turn, or nil
220
220
  # title: the session's own name (/title; desc shows it too), or nil;
221
221
  # auto_title: the one its recap gave it (RecapTitle), or nil