samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,635 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "session"
6
+ require_relative "turn_note"
7
+ require_relative "context_note"
8
+ require_relative "worker_idle_exit"
9
+ require_relative "session_manager"
10
+ require_relative "log"
11
+ require_relative "turn_flow"
12
+ require_relative "session_commands"
13
+ require_relative "model_profile"
14
+
15
+ module Samagotchi
16
+ # The loop of a background session worker, once it owns the session (see
17
+ # SessionManager.run_session_loop, which takes the OwnerLock around #run).
18
+ #
19
+ # It runs the session's Engine and Bridge, takes queued prompts from the
20
+ # input dir one turn at a time, and returns when nobody has used it for the
21
+ # idle-exit timeout. A failed turn is rolled back and its prompt handed back
22
+ # (:prompt_restored), as in the REPL, and the loop goes on. A stop marked on
23
+ # disk exits the process; an error outside a turn marks the session and
24
+ # exits with 1.
25
+ #
26
+ # The file IPC stays behind SessionManager's class methods
27
+ # (find_new_input_files, claim_input_file, start_bridge, ...), which specs
28
+ # stub as seams.
29
+ #
30
+ # The loop sleeps on a Waker, which the Bridge wakes when it queues a turn
31
+ # or a command, and the reminder callback when it queues one. A fallback
32
+ # tick picks up input written by another process (the web's
33
+ # write_turn_input fallback) and runs the stop-on-disk and idle-exit checks.
34
+ #
35
+ # Session commands (SessionCommands: /model, /models, !rollback, !cmd,
36
+ # /continue) run on the loop between turns, before any queued prompt. One
37
+ # taken while a turn runs (at an iteration boundary, or right after the
38
+ # turn) is refused as busy.
39
+ class Worker
40
+ FALLBACK_TICK_SECONDS = 5
41
+ # How much of a command's output goes into its :command_ran.
42
+ COMMAND_OUTPUT_LIMIT = 4096
43
+ BUSY_OUTPUT = "busy: wait for the turn to end"
44
+ TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
45
+ DEFAULT_MAX_ITERATIONS = 100
46
+ NO_INTERRUPT_MAX_ITERATIONS = 1000
47
+
48
+ # Wakes the worker loop. Whoever queues work writes it first and wakes
49
+ # after, and #wait drains every wake before the loop looks for work: a
50
+ # wake drained with the others was for work the loop is about to see,
51
+ # and one that comes later stays queued for the next #wait. So no wake is
52
+ # lost, and a burst of them costs one pass.
53
+ class Waker
54
+ def initialize
55
+ @queue = Thread::Queue.new
56
+ end
57
+
58
+ def wake
59
+ @queue << true
60
+ nil
61
+ end
62
+
63
+ # @return [Boolean] true when woken, false when the timeout passed
64
+ def wait(timeout)
65
+ woken = !@queue.pop(timeout: timeout).nil?
66
+ @queue.clear
67
+ woken
68
+ end
69
+ end
70
+
71
+ # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
72
+ # @param poll_interval [Numeric, nil] seconds between fallback ticks
73
+ def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil)
74
+ @session_id = session_id
75
+ @state_dir = state_dir
76
+ @session_dir = session_dir
77
+ @idle_exit_minutes = idle_exit_minutes
78
+ @poll_interval = poll_interval || FALLBACK_TICK_SECONDS
79
+ @waker = Waker.new
80
+ @command_queue = Thread::Queue.new
81
+ # The client_id of a client that asked the worker to exit (POST /exit).
82
+ @exit_requested = nil
83
+ @exit_requested_by = nil
84
+ # Set as it leaves: nothing happened in the session (#discard?).
85
+ @discard = false
86
+ end
87
+
88
+ # Whether the session was empty as the worker left it, so the caller
89
+ # deletes it once the lock is free (SessionManager.run_session_loop
90
+ # checks again then).
91
+ def discard? = @discard
92
+
93
+ # What a new session starts on (and /model resets to); nil before #run.
94
+ attr_reader :default_model
95
+
96
+ # @return [Symbol] :idle_exit, or :exit_requested when a client asked it
97
+ # to exit (Bridge POST /exit)
98
+ def run
99
+ @session = Session.load(@session_id, state_dir: @state_dir)
100
+ @engine = build_engine
101
+ # Before the Bridge serves anything: a UI joining a resumed worker's
102
+ # stream gets the session's history and status in its snapshot, not
103
+ # an empty session until the first turn.
104
+ @engine.session = @session
105
+ @turn_flow = TurnFlow.new(engine: @engine)
106
+ # A recap written while a continue offer waits says the turn stopped
107
+ # unfinished (before the idle jobs start).
108
+ @engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
109
+ # The seq of the last turn's end event: a command queued before it was
110
+ # queued while that turn ran.
111
+ @turn_end_seq = 0
112
+ @engine.subscribe(observer: lambda { |event|
113
+ @turn_end_seq = event[:event_seq] if TURN_END_EVENTS.include?(event[:type])
114
+ })
115
+ # /model's default is the config's, as in the REPL; the Engine started
116
+ # on the session's model.
117
+ @default_model = ModelProfile.required_model_name(nil)
118
+ @commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow,
119
+ default_model: @default_model, registry: @engine.command_registry,
120
+ save: ->(session) { session.save(state_dir: @state_dir) })
121
+ # Start the shared idle scheduler so the worker can trigger turns when
122
+ # reminders are due (even with no user input).
123
+ @engine.start_idle
124
+
125
+ @bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
126
+ on_input: -> { @waker.wake },
127
+ on_command: lambda { |command|
128
+ next start_anytime_command(command) if anytime_command?(command[:line])
129
+
130
+ # Called with the event log held: the
131
+ # count says which turn ends came before it.
132
+ @command_queue << command.merge(after_seq: @engine.event_count)
133
+ @waker.wake
134
+ },
135
+ on_exit_request: method(:exit_request),
136
+ # Decided again as it leaves (a note may still come in).
137
+ exit_discards: method(:empty_session?))
138
+ # What failed to load and what plugins showed as they loaded, now that
139
+ # a UI can be there (a late one gets them in the snapshot).
140
+ @engine.announce_load_events!
141
+ @idle_exit = WorkerIdleExit.new(
142
+ engine: @engine, bridge: @bridge,
143
+ timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
144
+ input_pending: -> { !@command_queue.empty? || !SessionManager.find_new_input_files(@session_dir).empty? },
145
+ awaiting_continue: -> { @turn_flow.awaiting_continue? }
146
+ )
147
+
148
+ begin
149
+ # A session stopped before this worker took the lock (e.g. a stop
150
+ # right after create) must not run its initial prompt.
151
+ exit(0) if stopped_on_disk?
152
+ # Plugins' slow setup (chi.init: an MCP server's first start), in
153
+ # the background, shown by the UIs; a turn waits only for the ones
154
+ # that bring tools.
155
+ @engine.start_init_tasks!
156
+ loop do
157
+ # Check if the session was externally marked as stopped
158
+ exit(0) if Session.load(@session_id, state_dir: @state_dir).status == Session::STATUS_STOPPED
159
+
160
+ # Commands queued before a prompt run first (a /continue sent
161
+ # before a new prompt still answers the offer).
162
+ next if run_queued_commands
163
+
164
+ # Between turns, so the next turn (the first one too) sees them.
165
+ absorb_notes
166
+
167
+ if (prompt = take_initial_prompt)
168
+ run_prompt(prompt, nil)
169
+ next
170
+ end
171
+
172
+ input_files = SessionManager.find_new_input_files(@session_dir)
173
+ if input_files.empty?
174
+ next if run_due_reminders
175
+ return left(:idle_exit) if @idle_exit.due? && leave_idle
176
+ return left(:exit_requested) if @exit_requested && leave_on_request
177
+
178
+ @waker.wait(@poll_interval)
179
+ next
180
+ end
181
+
182
+ input_files.sort.each do |input_file|
183
+ # A stop between two queued turns leaves the rest queued.
184
+ break if stopped_on_disk?
185
+
186
+ absorb_notes
187
+ run_input_file(input_file)
188
+ end
189
+ end
190
+ rescue StandardError => e
191
+ # Its stderr is /dev/null: the log is the only trace of why.
192
+ Log.exception(:worker, "crashed", e)
193
+ Session.mark_error(@session_id, reason: e.message, state_dir: @state_dir)
194
+ exit(1)
195
+ ensure
196
+ # The anytime commands finish and the plugins' services stop (a
197
+ # server process), whatever the way out; the Bridge last, so a
198
+ # command's command_ran still reaches its UI on a crash.
199
+ @engine&.shutdown
200
+ @bridge&.stop
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def build_engine
207
+ waker = @waker
208
+ engine = nil
209
+ engine = Samagotchi::Engine.new(
210
+ mode: @session.mode.to_sym,
211
+ model_name: @session.model_name,
212
+ # The session's --memory and --mute lists: the same prompt on every
213
+ # (re)spawn.
214
+ memories: @session.preloaded_memory_names,
215
+ muted_memories: @session.muted_memory_names,
216
+ reminders: {
217
+ callback: lambda { |due_names|
218
+ # A reminder is due: the loop runs a reminder turn for it once
219
+ # nothing else is queued (#run_due_reminders).
220
+ engine.note_due_reminders(due_names)
221
+ waker.wake
222
+ }
223
+ }
224
+ )
225
+ # The attached TUI and the web answer approvals; with none attached
226
+ # one waits, like ask_user_question.
227
+ engine.interface = :worker
228
+ engine.guardrail_state_dir = @state_dir if @state_dir
229
+ engine.session_state_dir = @state_dir if @state_dir
230
+ engine
231
+ end
232
+
233
+ # spawn_session hands the first prompt over in last_prompt, but
234
+ # last_prompt also records every later turn's prompt (and mark_error's
235
+ # reason), so only a session with no conversation yet has one pending; a
236
+ # resumed session must not replay its last turn. Taken once.
237
+ # @return [String, nil]
238
+ def take_initial_prompt
239
+ return nil if @initial_prompt_taken
240
+
241
+ @initial_prompt_taken = true
242
+ # A context note may have come before the first prompt ran.
243
+ return nil unless @session.messages.all? { |m| ContextNote.note?(m) } && !@session.last_prompt.to_s.strip.empty?
244
+
245
+ prompt = @session.last_prompt
246
+ @session.last_prompt = ""
247
+ @session.save(state_dir: @state_dir)
248
+ prompt
249
+ end
250
+
251
+ # Add the queued context notes to the conversation (between turns only,
252
+ # on this thread), save, then delete their files: a crash before the
253
+ # delete leaves them claimed, and Engine#add_context_note skips a note
254
+ # the saved conversation already holds. Not activity: a note alone
255
+ # neither starts a turn nor keeps an idle worker up.
256
+ def absorb_notes
257
+ files = SessionManager.find_new_note_files(@session_dir)
258
+ return if files.empty? || stopped_on_disk?
259
+
260
+ claimed = files.filter_map { |file| SessionManager.claim_note_file(file) }
261
+ claimed.each do |file|
262
+ note = SessionManager.read_note(file)
263
+ @engine.add_context_note(@session, note) if note
264
+ end
265
+ @session.save(state_dir: @state_dir)
266
+ claimed.each { |file| FileUtils.rm_f(file) }
267
+ end
268
+
269
+ def run_input_file(input_file)
270
+ claimed_file = SessionManager.claim_input_file(input_file)
271
+ return unless claimed_file
272
+
273
+ begin
274
+ message, origin, no_interrupt, images = SessionManager.read_input(claimed_file)
275
+ run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || []) unless message.to_s.strip.empty?
276
+ ensure
277
+ FileUtils.rm_f(claimed_file)
278
+ end
279
+ end
280
+
281
+ # @param no_interrupt [Boolean] an offer this turn makes keeps it for
282
+ # its continue turn
283
+ # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
284
+ def run_prompt(prompt, origin, no_interrupt: false, images: [])
285
+ # Show the turn as running to readers of the file (the web's session
286
+ # list); the Engine resets it to idle when it ends.
287
+ @session.status = Session::STATUS_RUNNING
288
+ @session.save(state_dir: @state_dir)
289
+ drop_continue_offer(origin)
290
+ @turn_flow.before_prompt_turn
291
+ @merged_this_turn = []
292
+ begin
293
+ result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, origin: origin,
294
+ max_iterations: max_iterations(no_interrupt), images: images)
295
+ rescue StandardError => e
296
+ # The Engine announced :turn_failed (with the error's one line).
297
+ restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: e)
298
+ return
299
+ ensure
300
+ refuse_queued_commands
301
+ end
302
+ after_turn(result, no_interrupt: no_interrupt)
303
+ response = result.respond_to?(:output) ? result.output : nil
304
+ SessionManager.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
305
+ @session.save(state_dir: @state_dir) unless stopped_on_disk?
306
+ end
307
+
308
+ # A due reminder runs as a continue turn, as in the REPL: Engine#run_turn
309
+ # injects the reminders as a tail system message, and no user message is
310
+ # added. A prompt's turn may have injected them already (a stale latch):
311
+ # then there is nothing to run.
312
+ # @return [Boolean] whether a reminder turn ran
313
+ def run_due_reminders
314
+ return false if @engine.due_reminder_names.empty?
315
+
316
+ @engine.clear_due_reminder_names!
317
+ return false unless @engine.reminders_due?
318
+
319
+ @session.status = Session::STATUS_RUNNING
320
+ @session.save(state_dir: @state_dir)
321
+ begin
322
+ result = @engine.run_turn(@session, nil, continue: true, pending_input: pending_input_drain,
323
+ origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
324
+ max_iterations: DEFAULT_MAX_ITERATIONS)
325
+ response = result.respond_to?(:output) ? result.output : nil
326
+ SessionManager.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
327
+ rescue StandardError
328
+ nil # the Engine announced :turn_failed; there is no prompt to hand back
329
+ ensure
330
+ refuse_queued_commands
331
+ end
332
+ # A pending continue offer stays (the REPL rule); otherwise the
333
+ # rollback window closes.
334
+ @turn_flow.after_reminder_turn
335
+ @session.save(state_dir: @state_dir) unless stopped_on_disk?
336
+ true
337
+ end
338
+
339
+ def max_iterations(no_interrupt) = no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : DEFAULT_MAX_ITERATIONS
340
+
341
+ # @return [Boolean] whether any command ran
342
+ def run_queued_commands
343
+ ran = false
344
+ while (command = next_command)
345
+ run_command(command)
346
+ ran = true
347
+ end
348
+ ran
349
+ end
350
+
351
+ # A command queued while a turn ran is refused (S1), not run after it:
352
+ # at the turn's iteration boundaries (mid_turn: all of them), and when
353
+ # it ends (the ones queued before its end event; later ones run next).
354
+ def refuse_queued_commands(mid_turn: false)
355
+ later = []
356
+ while (command = next_command)
357
+ if mid_turn || command[:after_seq].to_i < @turn_end_seq
358
+ announce_command(command, status: "busy", output: BUSY_OUTPUT, changed: [])
359
+ else
360
+ later << command
361
+ end
362
+ end
363
+ later.each { |command| @command_queue << command }
364
+ end
365
+
366
+ def next_command
367
+ @command_queue.pop(true)
368
+ rescue ThreadError
369
+ nil
370
+ end
371
+
372
+ def run_command(command)
373
+ awaiting = @turn_flow.awaiting_continue?
374
+ result, shown = run_command_line(command)
375
+ resolved = awaiting && result.decision && result.decision != :invalid
376
+ @engine.synchronize_events do
377
+ if resolved
378
+ @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
379
+ end
380
+ announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
381
+ shown.each { |event| @engine.announce(event) }
382
+ end
383
+ @session.save(state_dir: @state_dir) unless Array(result.changed).empty? || stopped_on_disk?
384
+ # An offer answered without a turn ("no") is activity: the recap
385
+ # written at the offer (it says the turn stopped) gets rewritten.
386
+ @engine.record_activity if resolved && !result.resume
387
+ run_continue_turn(command) if result.resume
388
+ end
389
+
390
+ # @return [Array(SessionCommands::Result, Array<Hash>)] the result, and
391
+ # the cards and notices it showed, held: they follow its command_ran,
392
+ # as its output
393
+ def run_command_line(command)
394
+ result, shown = @engine.holding_announcements do
395
+ @commands.run(command[:line])
396
+ rescue StandardError => e
397
+ SessionCommands::Result.new(status: :error, output: "#{command[:line].split.first}: #{e.message}", changed: [])
398
+ end
399
+ [result || SessionCommands::Result.new(status: :error, output: "not a session command", changed: []), shown]
400
+ end
401
+
402
+ def anytime_command?(line) = @engine.command_registry.lookup(line)&.anytime == true
403
+
404
+ # An anytime command (/help, a plugin's /btw; D8) runs now, on its own
405
+ # thread, never queued behind a turn: it is never busy. The Bridge calls
406
+ # this with the event log held, so its command_ran (announced when it
407
+ # finishes) comes after its command_queued. The cards it shows go out at
408
+ # once (btw's "thinking…" card), after its command_queued, which the
409
+ # UIs draw its line at (Engine#running_anytime). Its handler reads
410
+ # copies (ctx.messages) and shows things through ctx only; nothing is
411
+ # saved.
412
+ def start_anytime_command(command)
413
+ @engine.spawn_anytime do
414
+ result = begin
415
+ @engine.running_anytime { @commands.run(command[:line]) }
416
+ rescue StandardError => e
417
+ SessionCommands::Result.new(status: :error, output: "#{command[:line].split.first}: #{e.message}", changed: [])
418
+ end
419
+ result ||= SessionCommands::Result.new(status: :error, output: "not a session command", changed: [])
420
+ announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
421
+ anytime: true)
422
+ rescue StandardError => e
423
+ Log.warn(:worker, "anytime_command_failed", line: command[:line], error: e.class.name, msg: e.message)
424
+ end
425
+ end
426
+
427
+ # @param anytime [Boolean] an anytime command's: its line was shown at
428
+ # its command_queued already
429
+ def announce_command(command, status:, output:, changed:, anytime: false)
430
+ text = output.to_s
431
+ event = { type: :command_ran, command_id: command[:command_id], client_id: command[:client_id], line: command[:line],
432
+ status: status, output: text[0, COMMAND_OUTPUT_LIMIT], changed: changed.map(&:to_s),
433
+ model_name: @engine.effective_model_name }
434
+ event[:anytime] = true if anytime
435
+ event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
436
+ @engine.announce(event)
437
+ end
438
+
439
+ # The continue offer was answered yes: resume the conversation without a
440
+ # user message, with the iteration limit the offer's turn had. A failure
441
+ # keeps the offer, as the REPL does ("continue prompt preserved").
442
+ def run_continue_turn(command)
443
+ offer = @turn_flow.offer
444
+ @turn_flow.before_continue_turn
445
+ @session.status = Session::STATUS_RUNNING
446
+ @session.save(state_dir: @state_dir)
447
+ begin
448
+ result = @engine.run_turn(@session, nil, continue: true, pending_input: pending_input_drain,
449
+ origin: { client_id: command[:client_id] }.compact,
450
+ max_iterations: max_iterations(offer[:no_interrupt]))
451
+ rescue StandardError
452
+ @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
453
+ @session.save(state_dir: @state_dir) unless stopped_on_disk?
454
+ return
455
+ ensure
456
+ refuse_queued_commands
457
+ end
458
+ after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
459
+ response = result.respond_to?(:output) ? result.output : nil
460
+ SessionManager.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
461
+ @session.save(state_dir: @state_dir) unless stopped_on_disk?
462
+ end
463
+
464
+ # TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
465
+ # the continue offer of a turn that ran out of iterations, which every
466
+ # UI hears about.
467
+ def after_turn(result, continue: false, no_interrupt: false)
468
+ outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
469
+ return unless outcome == :continue_offered || outcome == :continue_cancelled
470
+
471
+ offer = @turn_flow.offer
472
+ @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
473
+ end
474
+
475
+ # A prompt taken while a continue is offered replaces the answer (D2):
476
+ # the offer goes, the partial turn stays.
477
+ def drop_continue_offer(origin)
478
+ return unless @turn_flow.awaiting_continue?
479
+
480
+ @engine.synchronize_events do
481
+ @turn_flow.drop_offer!
482
+ @engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
483
+ end
484
+ @engine.record_activity
485
+ end
486
+
487
+ # Back to the conversation before the failed turn, as the REPL does (so
488
+ # failed prompts don't pile up as consecutive user messages), and each
489
+ # prompt it took (its own and any merged into it) goes back to its
490
+ # sender, who can send it again. The rollback and the announcements are
491
+ # one step of the event log: a snapshot shows the failed turn's messages
492
+ # or the restored ones, never the one without the other.
493
+ # @param prompts [Array<Array(String, Hash|nil, Array|nil)>] [prompt,
494
+ # origin, images] (a web client gets its image chips back)
495
+ # @param error [Exception, nil] what failed: its one line stays in the
496
+ # conversation as a turn note
497
+ def restore_failed_turn(prompts, error: nil)
498
+ note = error && TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message, restored: true)
499
+ @engine.synchronize_events do
500
+ @turn_flow.prompt_turn_failed(note: note)
501
+ prompts.each do |prompt, origin, images|
502
+ restored = { type: :prompt_restored, prompt: prompt, origin: origin }
503
+ restored[:images] = images unless Array(images).empty?
504
+ @engine.announce(restored)
505
+ end
506
+ end
507
+ @session.save(state_dir: @state_dir) unless stopped_on_disk?
508
+ end
509
+
510
+ # Shared mid-turn steering drain: claims any input files that arrive
511
+ # while a turn is running and hands them to the agentic loop so
512
+ # follow-ups merge at the next iteration boundary instead of waiting
513
+ # for the turn to end. claim_input_file is atomic (rename), so a file
514
+ # consumed mid-turn simply fails the outer loop's later claim with
515
+ # ENOENT → nil. No double-processing risk.
516
+ #
517
+ # Runs on the turn thread; it announces who sent the merged input so
518
+ # every live UI can attribute it.
519
+ def pending_input_drain
520
+ @pending_input_drain ||= lambda do
521
+ # An iteration boundary: tell whoever sent a command now that it waits.
522
+ refuse_queued_commands(mid_turn: true)
523
+ # A line with images runs as its own next turn (steering merges text
524
+ # only), and so does anything queued after it, to keep the order.
525
+ files = SessionManager.find_new_input_files(@session_dir).sort
526
+ .take_while { |input_file| !SessionManager.input_has_images?(input_file) }
527
+ merged = files.filter_map do |input_file|
528
+ claimed_file = SessionManager.claim_input_file(input_file)
529
+ next unless claimed_file
530
+
531
+ begin
532
+ prompt, origin = SessionManager.read_input(claimed_file)
533
+ prompt = prompt.to_s.strip
534
+ prompt.empty? ? nil : [prompt, origin]
535
+ ensure
536
+ FileUtils.rm_f(claimed_file)
537
+ end
538
+ end
539
+ unless merged.empty?
540
+ @engine.announce(type: :input_merged, count: merged.size, origins: merged.filter_map(&:last))
541
+ @merged_this_turn.concat(merged)
542
+ end
543
+ merged.map(&:first)
544
+ end
545
+ end
546
+
547
+ # Check again with the event log held, which the Bridge holds while it
548
+ # queues a POST /turn, then close the Bridge so no client can queue one
549
+ # after the check, and stop the idle jobs (reminder callback, recap).
550
+ # A client connecting from here on finds no worker: `chi --attach` fails
551
+ # and the web stream answers 503 (a small window, left as is).
552
+ # @return [Boolean] false when something came in since #due?
553
+ def leave_idle
554
+ @engine.synchronize_events do
555
+ next false unless @idle_exit.due?
556
+
557
+ @bridge&.stop
558
+ @engine.stop_idle
559
+ log_idle_exit
560
+ true
561
+ end
562
+ end
563
+
564
+ # Bridge POST /exit, on a Bridge thread with the event log held.
565
+ # +delete+: the session is deleted after (/exit --delete), so no recap.
566
+ # @return [Symbol, nil] what keeps the worker up, nil when it will leave
567
+ def exit_request(client_id, delete: false)
568
+ # The Bridge serves before the idle-exit policy exists.
569
+ return :starting unless @idle_exit
570
+
571
+ hold = @idle_exit.hold_for_request(requester: client_id)
572
+ return hold if hold
573
+
574
+ @exit_requested = true
575
+ @exit_requested_by = client_id
576
+ @exit_deletes = delete
577
+ @waker.wake
578
+ nil
579
+ end
580
+
581
+ # #leave_idle for an exit a client asked for: check again with the event
582
+ # log held (a prompt may have come in since), then close the Bridge and
583
+ # stop the idle jobs. Other clients' streams no longer hold: the asker's
584
+ # may not have closed yet, and a UI joining now finds the worker gone
585
+ # (:stream_closed), the window #leave_idle has too.
586
+ # @return [Boolean] false when something came in since the request
587
+ def leave_on_request
588
+ @engine.synchronize_events do
589
+ hold = @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
590
+ if hold
591
+ @exit_requested = nil
592
+ Log.info(:worker, "exit_held", reason: hold)
593
+ next false
594
+ end
595
+
596
+ @bridge&.stop
597
+ @engine.stop_idle
598
+ Log.info(:worker, "exit_requested", by: @exit_requested_by || "a client")
599
+ true
600
+ end
601
+ end
602
+
603
+ # @return [Symbol] +result+, with #discard? decided and, for a session
604
+ # kept, its recap written: the Bridge is closed and the idle jobs are
605
+ # stopped by now, and nothing waits on this (the TUI has detached). A
606
+ # `chi send` meanwhile is picked up after the exit (run_session_loop);
607
+ # a `chi --attach` finds no Bridge until then.
608
+ def left(result)
609
+ @discard = empty_session?
610
+ write_recap_on_leave unless @discard || (result == :exit_requested && @exit_deletes)
611
+ result
612
+ end
613
+
614
+ def write_recap_on_leave
615
+ written = @engine.write_recap_now
616
+ Log.info(:worker, "recap_on_leave") if written
617
+ rescue StandardError => e
618
+ Log.warn(:worker, "recap_on_leave_failed", error: e.class.name, msg: e.message)
619
+ end
620
+
621
+ # Memory used before any turn saved it lives only in the Engine.
622
+ def empty_session?
623
+ SessionManager.discard_empty? && Array(@engine.used_memory_names).empty? &&
624
+ SessionManager.empty_session?(@session_id, state_dir: @state_dir, default_model: @default_model)
625
+ end
626
+
627
+ def log_idle_exit
628
+ Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
629
+ end
630
+
631
+ def stopped_on_disk?
632
+ SessionManager.stopped_on_disk?(@session_id, state_dir: @state_dir)
633
+ end
634
+ end
635
+ end