samagotchi 0.53.0 → 0.54.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/sessions.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  15. data/lib/samagotchi/bundle_command.rb +31 -36
  16. data/lib/samagotchi/bundle_needs.rb +4 -5
  17. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +6 -4
  22. data/lib/samagotchi/config.rb +0 -1
  23. data/lib/samagotchi/context_command.rb +1 -1
  24. data/lib/samagotchi/context_providers.rb +11 -14
  25. data/lib/samagotchi/context_status.rb +10 -18
  26. data/lib/samagotchi/engine.rb +117 -134
  27. data/lib/samagotchi/extension_load.rb +102 -0
  28. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  29. data/lib/samagotchi/kernel_loop.rb +5 -3
  30. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  31. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  32. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  33. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  34. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  35. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  36. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  37. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  39. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  41. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  43. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  44. data/lib/samagotchi/plugin/loader.rb +8 -8
  45. data/lib/samagotchi/self_report.rb +2 -2
  46. data/lib/samagotchi/send_command.rb +1 -1
  47. data/lib/samagotchi/session_continue.rb +275 -0
  48. data/lib/samagotchi/session_manager.rb +9 -253
  49. data/lib/samagotchi/session_metrics.rb +3 -1
  50. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  51. data/lib/samagotchi/terminal_ui.rb +12 -2
  52. data/lib/samagotchi/tool_activity.rb +84 -14
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +1 -1
  55. data/lib/samagotchi/web/public/activity.js +2 -0
  56. data/lib/samagotchi/web/public/app.js +6 -6
  57. data/lib/samagotchi/web/public/ctx.js +18 -4
  58. data/lib/samagotchi/web/public/index.html +4 -2
  59. data/lib/samagotchi/web/public/question_card.js +9 -1
  60. data/lib/samagotchi/web/public/question_cards.js +5 -3
  61. data/lib/samagotchi/web/public/timing.js +8 -2
  62. data/lib/samagotchi/web/public/turn_html.js +10 -6
  63. metadata +7 -1
@@ -339,7 +339,7 @@ module Samagotchi
339
339
  thinking: options[:thinking]),
340
340
  state_dir: @state_dir)
341
341
  end
342
- rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
342
+ rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
343
343
  error_line("chi send: refused: #{e.message}")
344
344
  return 1
345
345
  rescue StandardError => e
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "session"
5
+ require_relative "session_setup"
6
+ require_relative "session_inbox"
7
+ require_relative "session_chain"
8
+ require_relative "child_move"
9
+ require_relative "archive_store"
10
+ require_relative "recap_store"
11
+ require_relative "bridge_client"
12
+ require_relative "model_profile"
13
+ require_relative "log"
14
+ require_relative "session_manager"
15
+
16
+ module Samagotchi
17
+ # The next link of a session chain (chi send --new --continues, web
18
+ # Continue →): SessionManager.continue_session runs .run. The archive,
19
+ # spawn, stop and wake it builds on are SessionManager's.
20
+ module SessionContinue
21
+ # A continue that can't happen (#run). reason: :continued
22
+ # (another session continues it already: +ids+ names it), :open_children
23
+ # (open children it can't move to the new link: on an older chi's
24
+ # worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
25
+ # folder isn't there).
26
+ class Refused < StandardError
27
+ attr_reader :session_id, :reason, :ids
28
+
29
+ def initialize(session_id, reason, ids: [], detail: nil)
30
+ @session_id = session_id
31
+ @reason = reason
32
+ @ids = ids
33
+ super(continue_refused_message(detail))
34
+ end
35
+
36
+ private
37
+
38
+ def continue_refused_message(detail)
39
+ short = @session_id[0, 8]
40
+ case @reason
41
+ when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
42
+ when :open_children
43
+ "#{short} has delegates still open that can't move to the next link: #{detail}; " \
44
+ "then continue (or wait for them, stop them or archive them)"
45
+ else "#{short}'s folder #{detail} is gone"
46
+ end
47
+ end
48
+ end
49
+
50
+ # How long #run waits for the previous link's worker to write its
51
+ # recap (IdleRecap's own request timeout).
52
+ RECAP_WAIT = 30.0
53
+ # In the previous link's folder: one continue of a session at a time.
54
+ LOCK = "continue.lock"
55
+ # How often a last:<id> continue looks again when another continue
56
+ # moved the chain's end while it waited for the lock.
57
+ TRIES = 3
58
+
59
+ # Start the next link of a chain: a new session that continues
60
+ # +id_or_ref+ (Session#continues) in its folder, on its model (the name
61
+ # it was typed as, so an alias holds) and with its own setup (SessionSetup),
62
+ # starting with a context note from chi: the link and the previous
63
+ # link's recap (SessionChain.note_text). The previous link is archived
64
+ # first, with its finished delegates (archive_session's rules: a turn
65
+ # running, a prompt queued or the step-limit question refuse; an idle
66
+ # worker is stopped), so a refusal starts nothing. Its open delegates
67
+ # (running, waiting, live, an unreported reply, at any depth below them)
68
+ # move to the new link instead (ChildMove): their reports reach it.
69
+ # Before that its live worker is asked for a recap and given up to
70
+ # +recap_wait+ seconds to write it.
71
+ # One continue of a session at a time (a lock in its folder): a double
72
+ # click finds the first one's link and is refused; a last:<id> one
73
+ # follows the chain to its new end instead.
74
+ # @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
75
+ # @param prompt [String, nil] its first message (nil: it starts idle)
76
+ # @param title [String, nil] what the lists show before the first turn;
77
+ # by default the prompt, else the previous link's preview
78
+ # @return [Session] the new link (#model_warning as spawn_session's,
79
+ # #moved_children: the delegates it took over)
80
+ # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
81
+ # @raise [Refused] continued already, delegates open that can't
82
+ # move, or its folder is gone
83
+ # @raise [ArchiveRefused, OwnedByTUI] as archive_session
84
+ def self.run(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
85
+ sd = state_dir || Session.default_state_dir
86
+ TRIES.times do |try|
87
+ id = SessionChain.resolve(id_or_ref, state_dir: sd)
88
+ result = with_lock(id, sd) do
89
+ follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
90
+ next :moved if follow && SessionChain.next_of(id, state_dir: sd)
91
+
92
+ run_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
93
+ archive_wait: archive_wait)
94
+ end
95
+ return result unless result == :moved
96
+ end
97
+ end
98
+
99
+ private_class_method def self.with_lock(id, state_dir, &)
100
+ dir = Session.session_dir(id, state_dir: state_dir)
101
+ FileUtils.mkdir_p(dir)
102
+ File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
103
+ lock.flock(File::LOCK_EX)
104
+ yield
105
+ end
106
+ end
107
+
108
+ private_class_method def self.run_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
109
+ previous = Session.load(id, state_dir: state_dir)
110
+ plan = check!(previous, state_dir)
111
+ # archive_session's refusals that can be known now, before the wait.
112
+ SessionManager.archivable!(id, state_dir, except: plan.move)
113
+ recap_before_archive(id, state_dir, wait: recap_wait)
114
+ # Again: a turn sent to it during the wait may have delegated (they
115
+ # move too), or a delegate finished.
116
+ plan = move_plan!(id, state_dir)
117
+ already = [id, *SessionManager.descendant_ids(id, state_dir, except: plan.move)].select do |sid|
118
+ ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
119
+ end
120
+ archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
121
+ archived = archive[:archived] - already
122
+ begin
123
+ spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
124
+ rescue StandardError
125
+ # Nothing started: the delegates go back, what this archived goes
126
+ # back to the lists, and a new link saved before its worker failed
127
+ # to spawn goes (left, it would hold the chain: "continued already",
128
+ # last: to it).
129
+ undo_move(id, state_dir, moved: plan.move)
130
+ begin
131
+ discard_failed_link(id, state_dir)
132
+ ensure
133
+ archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
134
+ wake_for_rings(id, state_dir) unless plan.move.empty?
135
+ end
136
+ raise
137
+ end
138
+ end
139
+
140
+ # The delegates a failed #run moved, or began to (its
141
+ # intent names them), go back to +id+.
142
+ private_class_method def self.undo_move(id, state_dir, moved:)
143
+ link = SessionChain.next_of(id, state_dir: state_dir) or return
144
+ ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
145
+ ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
146
+ ChildMove.clear_intent(link, state_dir: state_dir)
147
+ rescue StandardError => e
148
+ Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
149
+ end
150
+
151
+ # The link a failed #run saved (no other continues +id+:
152
+ # check! made sure under the lock): its worker stopped if one
153
+ # started, then the rings a moved delegate wrote into it just before
154
+ # the undo go back to +id+, then the link goes. A worker that outlives
155
+ # the stop is raised (SessionManager::DeleteRefused): the link stays,
156
+ # and the caller hears it.
157
+ private_class_method def self.discard_failed_link(id, state_dir)
158
+ orphan = SessionChain.next_of(id, state_dir: state_dir) or return
159
+ if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
160
+ !SessionManager.stop_session(orphan, state_dir: state_dir, wait: 10)
161
+ raise SessionManager::DeleteRefused.new(orphan, :still_stopping)
162
+ end
163
+
164
+ ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
165
+ SessionManager.delete_session(orphan, state_dir: state_dir)
166
+ rescue SessionManager::DeleteRefused
167
+ raise
168
+ rescue StandardError => e
169
+ Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
170
+ end
171
+
172
+ # A failed continue's previous link, back in the lists, with rings that
173
+ # came in meanwhile: wake it for them, as the ring's own wake would have.
174
+ private_class_method def self.wake_for_rings(id, state_dir)
175
+ return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
176
+
177
+ SessionManager.wake_for_report(id, state_dir: state_dir)
178
+ rescue StandardError => e
179
+ Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
180
+ end
181
+
182
+ # The new link, holding off any worker but its own (ChildMove.starting?)
183
+ # while +move+ goes to it.
184
+ private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
185
+ recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
186
+ start = prompt.to_s.strip.empty? ? nil : prompt
187
+ title = previous.first_preview if title.to_s.strip.empty? && start.nil?
188
+ before_spawn = lambda do |link|
189
+ ChildMove.mark_starting(link.id, state_dir: state_dir)
190
+ ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
191
+ end
192
+ link = SessionManager.spawn_session(
193
+ prompt: start, title: title, working_directory: previous.working_directory,
194
+ model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
195
+ continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
196
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
197
+ )
198
+ link.moved_children = move
199
+ link
200
+ end
201
+
202
+ # The refusals that come before anything changes: continued already (no
203
+ # forks in a chain), an open delegate that can't move to the new link
204
+ # (ChildMove.plan), the folder gone (the worker would run elsewhere), a
205
+ # model whose host config.yml no longer has.
206
+ # @return [ChildMove::Plan]
207
+ # @raise [Refused, ModelProfile::MissingModel]
208
+ private_class_method def self.check!(previous, state_dir)
209
+ if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
210
+ raise Refused.new(previous.id, :continued, ids: [following])
211
+ end
212
+
213
+ plan = move_plan!(previous.id, state_dir)
214
+ # Its model's host, as SessionManager.spawn_session checks it: before
215
+ # the archive stops the previous link's worker.
216
+ ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
217
+ dir = previous.working_directory.to_s
218
+ raise Refused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
219
+
220
+ plan
221
+ end
222
+
223
+ # A child still open below a session (#open_children); why: running,
224
+ # waiting, live, unreported reply, or why it can't move (ChildMove).
225
+ OpenChild = Data.define(:id, :short_id, :why)
226
+
227
+ # The delegates a continue of +id+ moves (ChildMove.plan).
228
+ # @return [ChildMove::Plan]
229
+ # @raise [Refused] for open ones it can't move
230
+ private_class_method def self.move_plan!(id, state_dir)
231
+ plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
232
+ return plan if plan.refuse.empty?
233
+
234
+ raise Refused.new(id, :open_children, ids: plan.refuse.map(&:id),
235
+ detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
236
+ end
237
+
238
+ # The unarchived children, at any depth (the archive's cascade), of the
239
+ # session still running, waiting for an answer, with a live worker, or
240
+ # (a delegate) with a reply its parent wasn't given (ChildrenStatus).
241
+ # @return [Array<OpenChild>]
242
+ def self.open_children(id, state_dir)
243
+ require_relative "children_status"
244
+ nodes = [id, *SessionManager.descendant_ids(id, state_dir)]
245
+ nodes.flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id).filter_map do |child|
246
+ why = if %w[waiting running].include?(child.state) then child.state
247
+ elsif child.live then "live"
248
+ elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
249
+ end
250
+ why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
251
+ end
252
+ end
253
+
254
+ # Ask the session's live worker for a recap now (Bridge POST /recap:
255
+ # IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
256
+ # to change. A worker that is gone wrote its recap when it left; one
257
+ # that has nothing new to say, too short a conversation, or recaps off
258
+ # answers at once. Best effort: the continue goes on without.
259
+ private_class_method def self.recap_before_archive(id, state_dir, wait:)
260
+ return unless SessionManager.worker_live?(id, state_dir: state_dir)
261
+
262
+ dir = Session.session_dir(id, state_dir: state_dir)
263
+ client = BridgeClient.discover(id, session_dir: dir) or return
264
+ before = RecapStore.read(dir)
265
+ reply = client.request_recap
266
+ asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
267
+ return unless %w[started in_flight].include?(asked)
268
+
269
+ BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
270
+ rescue StandardError => e
271
+ Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
272
+ nil
273
+ end
274
+ end
275
+ end
@@ -65,6 +65,8 @@ module Samagotchi
65
65
  autoload :Worker, File.expand_path("worker", __dir__)
66
66
  # session_retention requires this file.
67
67
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
68
+ # session_continue requires this file.
69
+ autoload :SessionContinue, File.expand_path("session_continue", __dir__)
68
70
 
69
71
  class SessionManager
70
72
  # Raised when the interactive TUI owns the session: it runs its own Engine
@@ -120,35 +122,6 @@ module Samagotchi
120
122
  end
121
123
  end
122
124
 
123
- # A continue that can't happen (#continue_session). reason: :continued
124
- # (another session continues it already: +ids+ names it), :open_children
125
- # (open children it can't move to the new link: on an older chi's
126
- # worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
127
- # folder isn't there).
128
- class ContinueRefused < StandardError
129
- attr_reader :session_id, :reason, :ids
130
-
131
- def initialize(session_id, reason, ids: [], detail: nil)
132
- @session_id = session_id
133
- @reason = reason
134
- @ids = ids
135
- super(continue_refused_message(detail))
136
- end
137
-
138
- private
139
-
140
- def continue_refused_message(detail)
141
- short = @session_id[0, 8]
142
- case @reason
143
- when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
144
- when :open_children
145
- "#{short} has delegates still open that can't move to the next link: #{detail}; " \
146
- "then continue (or wait for them, stop them or archive them)"
147
- else "#{short}'s folder #{detail} is gone"
148
- end
149
- end
150
- end
151
-
152
125
  # Spawn a new background session that processes the given prompt (or,
153
126
  # with none, waits idle for input).
154
127
  #
@@ -168,7 +141,7 @@ module Samagotchi
168
141
  # @param setup [SessionSetup] the session's own settings (chi
169
142
  # --llm-context; a fork and a continue copy their session's)
170
143
  # @param continues [String, nil] the session this one continues (a
171
- # session field; #continue_session)
144
+ # session field; SessionContinue)
172
145
  # @param note [String, nil] a context note the session starts with,
173
146
  # from +note_source+: queued before its worker spawns, so its first
174
147
  # turn sees it
@@ -223,227 +196,9 @@ module Samagotchi
223
196
  session
224
197
  end
225
198
 
226
- # How long #continue_session waits for the previous link's worker to
227
- # write its recap (IdleRecap's own request timeout).
228
- CONTINUE_RECAP_WAIT = 30.0
229
- # In the previous link's folder: one continue of a session at a time.
230
- CONTINUE_LOCK = "continue.lock"
231
- # How often a last:<id> continue looks again when another continue
232
- # moved the chain's end while it waited for the lock.
233
- CONTINUE_TRIES = 3
234
-
235
- # Start the next link of a chain: a new session that continues
236
- # +id_or_ref+ (Session#continues) in its folder, on its model (the name
237
- # it was typed as, so an alias holds) and with its own setup (SessionSetup),
238
- # starting with a context note from chi: the link and the previous
239
- # link's recap (SessionChain.note_text). The previous link is archived
240
- # first, with its finished delegates (archive_session's rules: a turn
241
- # running, a prompt queued or the step-limit question refuse; an idle
242
- # worker is stopped), so a refusal starts nothing. Its open delegates
243
- # (running, waiting, live, an unreported reply, at any depth below them)
244
- # move to the new link instead (ChildMove): their reports reach it.
245
- # Before that its live worker is asked for a recap and given up to
246
- # +recap_wait+ seconds to write it.
247
- # One continue of a session at a time (a lock in its folder): a double
248
- # click finds the first one's link and is refused; a last:<id> one
249
- # follows the chain to its new end instead.
250
- # @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
251
- # @param prompt [String, nil] its first message (nil: it starts idle)
252
- # @param title [String, nil] what the lists show before the first turn;
253
- # by default the prompt, else the previous link's preview
254
- # @return [Session] the new link (#model_warning as spawn_session's,
255
- # #moved_children: the delegates it took over)
256
- # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
257
- # @raise [ContinueRefused] continued already, delegates open that can't
258
- # move, or its folder is gone
259
- # @raise [ArchiveRefused, OwnedByTUI] as archive_session
260
- def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
261
- archive_wait: 5)
262
- sd = state_dir || Session.default_state_dir
263
- CONTINUE_TRIES.times do |try|
264
- id = SessionChain.resolve(id_or_ref, state_dir: sd)
265
- result = with_continue_lock(id, sd) do
266
- follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
267
- next :moved if follow && SessionChain.next_of(id, state_dir: sd)
268
-
269
- continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
270
- archive_wait: archive_wait)
271
- end
272
- return result unless result == :moved
273
- end
274
- end
275
-
276
- private_class_method def self.with_continue_lock(id, state_dir, &)
277
- dir = Session.session_dir(id, state_dir: state_dir)
278
- FileUtils.mkdir_p(dir)
279
- File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
280
- lock.flock(File::LOCK_EX)
281
- yield
282
- end
283
- end
284
-
285
- private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
286
- previous = Session.load(id, state_dir: state_dir)
287
- plan = check_continue!(previous, state_dir)
288
- # archive_session's refusals that can be known now, before the wait.
289
- archivable!(id, state_dir, except: plan.move)
290
- recap_before_archive(id, state_dir, wait: recap_wait)
291
- # Again: a turn sent to it during the wait may have delegated (they
292
- # move too), or a delegate finished.
293
- plan = move_plan!(id, state_dir)
294
- already = [id, *descendant_ids(id, state_dir, except: plan.move)].select do |sid|
295
- ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
296
- end
297
- archived = archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)[:archived] - already
298
- begin
299
- spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
300
- rescue StandardError
301
- # Nothing started: the delegates go back, what this archived goes
302
- # back to the lists, and a new link saved before its worker failed
303
- # to spawn goes (left, it would hold the chain: "continued already",
304
- # last: to it).
305
- undo_move(id, state_dir, moved: plan.move)
306
- begin
307
- discard_failed_link(id, state_dir)
308
- ensure
309
- archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
310
- wake_for_rings(id, state_dir) unless plan.move.empty?
311
- end
312
- raise
313
- end
314
- end
315
-
316
- # The delegates a failed #continue_session moved, or began to (its
317
- # intent names them), go back to +id+.
318
- private_class_method def self.undo_move(id, state_dir, moved:)
319
- link = SessionChain.next_of(id, state_dir: state_dir) or return
320
- ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
321
- ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
322
- ChildMove.clear_intent(link, state_dir: state_dir)
323
- rescue StandardError => e
324
- Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
325
- end
326
-
327
- # The link a failed #continue_session saved (no other continues +id+:
328
- # check_continue! made sure under the lock): its worker stopped if one
329
- # started, then the rings a moved delegate wrote into it just before
330
- # the undo go back to +id+, then the link goes. A worker that outlives
331
- # the stop is raised (DeleteRefused): the link stays, and the caller
332
- # hears it.
333
- private_class_method def self.discard_failed_link(id, state_dir)
334
- orphan = SessionChain.next_of(id, state_dir: state_dir) or return
335
- if refuse_tui!(orphan, state_dir: state_dir) && !stop_session(orphan, state_dir: state_dir, wait: 10)
336
- raise DeleteRefused.new(orphan, :still_stopping)
337
- end
338
-
339
- ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
340
- delete_session(orphan, state_dir: state_dir)
341
- rescue DeleteRefused
342
- raise
343
- rescue StandardError => e
344
- Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
345
- end
346
-
347
- # A failed continue's previous link, back in the lists, with rings that
348
- # came in meanwhile: wake it for them, as the ring's own wake would have.
349
- private_class_method def self.wake_for_rings(id, state_dir)
350
- return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
351
-
352
- wake_for_report(id, state_dir: state_dir)
353
- rescue StandardError => e
354
- Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
355
- end
356
-
357
- # The new link, holding off any worker but its own (ChildMove.starting?)
358
- # while +move+ goes to it.
359
- private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
360
- recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
361
- start = prompt.to_s.strip.empty? ? nil : prompt
362
- title = previous.first_preview if title.to_s.strip.empty? && start.nil?
363
- before_spawn = lambda do |link|
364
- ChildMove.mark_starting(link.id, state_dir: state_dir)
365
- ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
366
- end
367
- link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
368
- model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
369
- continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
370
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
371
- link.moved_children = move
372
- link
373
- end
374
-
375
- # The refusals that come before anything changes: continued already (no
376
- # forks in a chain), an open delegate that can't move to the new link
377
- # (ChildMove.plan), the folder gone (the worker would run elsewhere), a
378
- # model whose host config.yml no longer has.
379
- # @return [ChildMove::Plan]
380
- # @raise [ContinueRefused, ModelProfile::MissingModel]
381
- private_class_method def self.check_continue!(previous, state_dir)
382
- if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
383
- raise ContinueRefused.new(previous.id, :continued, ids: [following])
384
- end
385
-
386
- plan = move_plan!(previous.id, state_dir)
387
- # Its model's host, as spawn_session checks it: before the archive
388
- # stops the previous link's worker.
389
- ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
390
- dir = previous.working_directory.to_s
391
- raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
392
-
393
- plan
394
- end
395
-
396
- # A child still open below a session (#open_children); why: running,
397
- # waiting, live, unreported reply, or why it can't move (ChildMove).
398
- OpenChild = Data.define(:id, :short_id, :why)
399
-
400
- # The delegates a continue of +id+ moves (ChildMove.plan).
401
- # @return [ChildMove::Plan]
402
- # @raise [ContinueRefused] for open ones it can't move
403
- private_class_method def self.move_plan!(id, state_dir)
404
- plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
405
- return plan if plan.refuse.empty?
406
-
407
- raise ContinueRefused.new(id, :open_children, ids: plan.refuse.map(&:id),
408
- detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
409
- end
410
-
411
- # The unarchived children, at any depth (the archive's cascade), of the
412
- # session still running, waiting for an answer, with a live worker, or
413
- # (a delegate) with a reply its parent wasn't given (ChildrenStatus).
414
- # @return [Array<OpenChild>]
415
- def self.open_children(id, state_dir)
416
- require_relative "children_status"
417
- [id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
418
- .filter_map do |child|
419
- why = if %w[waiting running].include?(child.state) then child.state
420
- elsif child.live then "live"
421
- elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
422
- end
423
- why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
424
- end
425
- end
426
-
427
- # Ask the session's live worker for a recap now (Bridge POST /recap:
428
- # IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
429
- # to change. A worker that is gone wrote its recap when it left; one
430
- # that has nothing new to say, too short a conversation, or recaps off
431
- # answers at once. Best effort: the continue goes on without.
432
- private_class_method def self.recap_before_archive(id, state_dir, wait:)
433
- return unless worker_live?(id, state_dir: state_dir)
434
-
435
- dir = Session.session_dir(id, state_dir: state_dir)
436
- client = BridgeClient.discover(id, session_dir: dir) or return
437
- before = RecapStore.read(dir)
438
- reply = client.request_recap
439
- asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
440
- return unless %w[started in_flight].include?(asked)
441
-
442
- BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
443
- rescue StandardError => e
444
- Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
445
- nil
446
- end
199
+ # Start the next link of a chain (SessionContinue.run, which has the
200
+ # rules and raises SessionContinue::Refused).
201
+ def self.continue_session(...) = SessionContinue.run(...)
447
202
 
448
203
  # Build the opts hash passed to Process.spawn for a forked worker. The
449
204
  # child inherits this process's ENV and reads config.yml itself (as it
@@ -637,10 +392,11 @@ module Samagotchi
637
392
  end
638
393
 
639
394
  # The session and its descendants, with their owners, when they can be
640
- # archived now (#archive_refusal for each), else the refusal.
395
+ # archived now (#archive_refusal for each), else the refusal. Public for
396
+ # SessionContinue, which checks before its recap wait.
641
397
  # @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
642
398
  # @raise [ArchiveRefused, OwnedByTUI]
643
- private_class_method def self.archivable!(id, state_dir, except: [])
399
+ def self.archivable!(id, state_dir, except: [])
644
400
  raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
645
401
 
646
402
  tree = [id, *descendant_ids(id, state_dir, except: except)]
@@ -824,6 +824,8 @@ module Samagotchi
824
824
 
825
825
  called_as = event.dig(:activity, :called_as)
826
826
  active[:called_as] = called_as.to_s if called_as
827
+ # A grep that found nothing (ok): the reloaded web row says "no match".
828
+ active[:no_match] = true if event.dig(:activity, :no_match)
827
829
  finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
828
830
  end
829
831
  end
@@ -831,7 +833,7 @@ module Samagotchi
831
833
  # Caller holds the mutex.
832
834
  def finish_tool_record(active, status, waited_ms: 0)
833
835
  record = active.slice(
834
- :id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
836
+ :id, :turn_id, :iteration, :call_index, :tool, :called_as, :no_match, :started_at
835
837
  ).merge(
836
838
  status: status,
837
839
  completed_at: now.iso8601(3),
@@ -1268,7 +1268,8 @@ module Samagotchi
1268
1268
  # (it ended first).
1269
1269
  def join_tool_line(part, duration_ms: nil)
1270
1270
  output = part[:output]
1271
- status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
1271
+ command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1272
+ status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool], command: command)
1272
1273
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1273
1274
  params: part[:params], status: status,
1274
1275
  description: ToolView.description_title(part[:view] || {}) }
@@ -273,6 +273,16 @@ module Samagotchi
273
273
  return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
274
274
 
275
275
  if @prompt && @non_interactive
276
+ # A /cut TEXT / /queue TEXT prompt runs its text as the turn's
277
+ # prompt, the same as the idle REPL does (Formatting#steer_choice);
278
+ # a bare /cut / /queue (no text) is refused above as a UI command,
279
+ # so only the text here. The images are the text's, not the words'.
280
+ one_shot_prompt =
281
+ if (choice = steer_choice(@prompt)) && !choice.last.empty?
282
+ choice.last
283
+ else
284
+ @prompt
285
+ end
276
286
  # Headless / CI mode: run directly without TTY rendering.
277
287
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
278
288
  # Retry lines and hook notices on stderr; stdout gets the answer below.
@@ -281,11 +291,11 @@ module Samagotchi
281
291
  begin
282
292
  result = @engine.run_turn(
283
293
  session,
284
- @prompt,
294
+ one_shot_prompt,
285
295
  on_event: sink,
286
296
  max_iterations: IterationLimit.for(no_interrupt: true),
287
297
  cancel_controller: nil,
288
- images: ImageInput.extract(@prompt)
298
+ images: ImageInput.extract(one_shot_prompt)
289
299
  )
290
300
  rescue LLM::ProviderError, ImageStore::Error => e
291
301
  return one_shot_failed(session, e)