samagotchi 0.46.0 → 0.47.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/docs/cli.md +14 -8
  4. data/docs/configuration.md +3 -1
  5. data/docs/desktop.md +2 -1
  6. data/docs/plugins.md +6 -0
  7. data/docs/sessions.md +24 -6
  8. data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
  9. data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
  10. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
  11. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/config.rb +5 -1
  15. data/lib/samagotchi/engine.rb +4 -5
  16. data/lib/samagotchi/host_model.rb +14 -7
  17. data/lib/samagotchi/host_registry.rb +6 -1
  18. data/lib/samagotchi/llm/chat_loop.rb +27 -7
  19. data/lib/samagotchi/llm/errors.rb +8 -2
  20. data/lib/samagotchi/llm_context_strategy.rb +1 -0
  21. data/lib/samagotchi/model_catalog.rb +13 -12
  22. data/lib/samagotchi/model_profile.rb +4 -3
  23. data/lib/samagotchi/models_command.rb +12 -5
  24. data/lib/samagotchi/question_desk.rb +29 -4
  25. data/lib/samagotchi/send_command.rb +59 -9
  26. data/lib/samagotchi/served_model.rb +9 -0
  27. data/lib/samagotchi/session.rb +13 -4
  28. data/lib/samagotchi/session_chain.rb +93 -0
  29. data/lib/samagotchi/session_commands.rb +8 -7
  30. data/lib/samagotchi/session_manager.rb +225 -14
  31. data/lib/samagotchi/sessions_command.rb +5 -1
  32. data/lib/samagotchi/terminal_ui/formatting.rb +26 -11
  33. data/lib/samagotchi/terminal_ui.rb +4 -2
  34. data/lib/samagotchi/version.rb +1 -1
  35. data/lib/samagotchi/web/app.rb +86 -4
  36. data/lib/samagotchi/web/public/app.js +200 -18
  37. data/lib/samagotchi/web/public/chains.js +234 -0
  38. data/lib/samagotchi/web/public/data.js +12 -0
  39. data/lib/samagotchi/web/public/families_view.js +3 -2
  40. data/lib/samagotchi/web/public/index.html +32 -6
  41. data/lib/samagotchi/web/public/model_pick.js +22 -15
  42. data/lib/samagotchi/web/public/model_picker.js +6 -3
  43. data/lib/samagotchi/web/public/question_card.js +8 -0
  44. data/lib/samagotchi/web/public/session_state.js +2 -0
  45. data/lib/samagotchi/web/public/sessions_list.js +4 -2
  46. data/lib/samagotchi/web/public/turn_events.js +14 -6
  47. data/lib/samagotchi/web/session_summary.rb +3 -0
  48. data/lib/samagotchi/worker.rb +7 -3
  49. metadata +3 -1
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "session"
5
+ require_relative "recap_store"
6
+
7
+ module Samagotchi
8
+ # Session chains: a session that continues an earlier one (Session#continues),
9
+ # "the next day of the same routine". The chain is derived, never stored:
10
+ # follow +continues+ back for the earlier links, forward (the session whose
11
+ # +continues+ names this one) for the later ones. A link is continued at
12
+ # most once (SessionManager.continue_session refuses a second), so the
13
+ # chain's latest link is the one nobody continues.
14
+ module SessionChain
15
+ # `--continues last:<id>`: the latest link of the chain <id> is in.
16
+ LAST_PREFIX = "last:"
17
+ # Who the carry note is from (ContextNote's label: "note from session chain").
18
+ NOTE_SOURCE = "session chain"
19
+ # The most of the previous link's recap the carry note takes; a recap is
20
+ # a few sentences, this only keeps a runaway one under the note's 16 KiB.
21
+ RECAP_CHARS = 8000
22
+
23
+ module_function
24
+
25
+ # The session that continues +id+, else nil. Archived ones count: an
26
+ # archived link is still a link.
27
+ # @return [String, nil]
28
+ def next_of(id, state_dir:)
29
+ Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
30
+ .find { |s| s.continues == id }&.id
31
+ end
32
+
33
+ # The chain's latest link from +id+ on: +id+ itself when nobody
34
+ # continues it. One listing, walked forward (a loop a hand-edited file
35
+ # could make ends where it started).
36
+ # @return [String]
37
+ def latest(id, state_dir:)
38
+ nexts = {}
39
+ Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true).each do |s|
40
+ nexts[s.continues] ||= s.id if s.continues
41
+ end
42
+ seen = [id]
43
+ while (following = nexts[seen.last]) && !seen.include?(following)
44
+ seen << following
45
+ end
46
+ seen.last
47
+ end
48
+
49
+ # A session to continue as the CLI and the web name it: an id, a unique
50
+ # prefix, or `last:<id or prefix>` for that chain's latest link.
51
+ # @return [String] the full id of an existing session
52
+ # @raise [ArgumentError] none such (Session::AmbiguousId for a prefix of several)
53
+ def resolve(ref, state_dir:)
54
+ given = ref.to_s.strip
55
+ last = given.start_with?(LAST_PREFIX)
56
+ given = given.delete_prefix(LAST_PREFIX) if last
57
+ raise ArgumentError, "no session #{ref}" unless Session.valid_id?(given)
58
+
59
+ id = Session.resolve_id(given, state_dir: state_dir)
60
+ raise ArgumentError, "no session #{ref}" unless Session.exist?(id, state_dir: state_dir)
61
+
62
+ last ? latest(id, state_dir: state_dir) : id
63
+ end
64
+
65
+ # The context note a new link starts with: which session it continues,
66
+ # from when, and that session's recap with its time, when it has one.
67
+ # Never more than the recap: no transcript.
68
+ # @param previous [Session]
69
+ # @param recap [Hash, nil] RecapStore.read's
70
+ # @return [String]
71
+ def note_text(previous, recap:)
72
+ head = "This session continues #{previous.id[0, 8]} (#{day(previous.created_at)}), the previous link of its chain."
73
+ text = recap && recap[:text].to_s.strip
74
+ return "#{head} It left no recap." if text.nil? || text.empty?
75
+
76
+ text = "#{text[0, RECAP_CHARS - 1]}…" if text.length > RECAP_CHARS
77
+ as_of = clock(recap[:created_at])
78
+ "#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
79
+ end
80
+
81
+ def day(iso)
82
+ Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
83
+ rescue ArgumentError
84
+ iso.to_s
85
+ end
86
+
87
+ def clock(iso)
88
+ iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
89
+ rescue ArgumentError
90
+ nil
91
+ end
92
+ end
93
+ end
@@ -573,14 +573,14 @@ module Samagotchi
573
573
  results.keys.sort.each do |hname|
574
574
  data = results[hname]
575
575
  host_label = "#{hname} (#{data[:host]}:#{data[:port]})"
576
+ # Declared ids (hosts.<name>.models) first, as "(config)", never cut
577
+ # by MODELS_PER_HOST; a host that failed keeps only those, as
578
+ # "(config, host down)" under its unreachable line.
579
+ rows = host_rows.fetch(hname, [])
576
580
  if data[:error]
577
581
  lines << "#{host_label} — unreachable: #{data[:error]}"
578
- next
579
- end
580
- # Declared ids (hosts.<name>.models) first, as "(config)", never cut
581
- # by MODELS_PER_HOST.
582
- rows = host_rows[hname]
583
- if rows.empty?
582
+ next if rows.empty?
583
+ elsif rows.empty?
584
584
  lines << "#{host_label} — no models discovered"
585
585
  next
586
586
  end
@@ -599,13 +599,14 @@ module Samagotchi
599
599
  end
600
600
  next if declared.empty? && shown.empty?
601
601
 
602
- lines << "#{host_label}:"
602
+ lines << "#{host_label}:" unless data[:error]
603
603
  hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
604
604
  (declared + shown.first(shown.size - hidden)).each do |row, identifier|
605
605
  raw = row.info&.raw || {}
606
606
  raw_status = raw["status"] || raw[:status]
607
607
  status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
608
608
  status = ["config", status].reject { |s| s.to_s.empty? }.join(", ") if row.configured
609
+ status = "#{status}, host down" if row.unavailable
609
610
  base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
610
611
  alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
611
612
  alias_list.uniq!
@@ -30,6 +30,7 @@ require_relative "continue_offer"
30
30
  require_relative "image_store"
31
31
  require_relative "plugin_session_state"
32
32
  require_relative "context_sources"
33
+ require_relative "session_chain"
33
34
  require_relative "terminal_ui"
34
35
 
35
36
  module Samagotchi
@@ -115,6 +116,33 @@ module Samagotchi
115
116
  end
116
117
  end
117
118
 
119
+ # A continue that can't happen (#continue_session). reason: :continued
120
+ # (another session continues it already: +ids+ names it), :open_children
121
+ # (children still running, waiting, live or with a reply it wasn't given:
122
+ # +ids+ names them) or :folder_gone (its folder isn't there).
123
+ class ContinueRefused < StandardError
124
+ attr_reader :session_id, :reason, :ids
125
+
126
+ def initialize(session_id, reason, ids: [], detail: nil)
127
+ @session_id = session_id
128
+ @reason = reason
129
+ @ids = ids
130
+ super(continue_refused_message(detail))
131
+ end
132
+
133
+ private
134
+
135
+ def continue_refused_message(detail)
136
+ short = @session_id[0, 8]
137
+ case @reason
138
+ when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
139
+ when :open_children
140
+ "#{short} has delegates still open: #{detail}; wait for them, stop them or archive them, then continue"
141
+ else "#{short}'s folder #{detail} is gone"
142
+ end
143
+ end
144
+ end
145
+
118
146
  # Spawn a new background session that processes the given prompt (or,
119
147
  # with none, waits idle for input).
120
148
  #
@@ -133,11 +161,17 @@ module Samagotchi
133
161
  # (the prompt's preview by default, else the seed's first user message)
134
162
  # @param llm_context [LLMContextOverride, nil] the session's own
135
163
  # llm_context values (chi --llm-context; a fork copies its parent's)
164
+ # @param continues [String, nil] the session this one continues (a
165
+ # session field; #continue_session)
166
+ # @param note [String, nil] a context note the session starts with,
167
+ # from +note_source+: queued before its worker spawns, so its first
168
+ # turn sees it
136
169
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
137
170
  # and #model_warning: an id its host's saved list doesn't have
138
171
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
139
172
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
140
- title: nil, delegate: false, llm_context: nil)
173
+ title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
174
+ note_source: "chi")
141
175
  sd = state_dir || Session.default_state_dir
142
176
  # The resolved ref is stored (a resumed session keeps its model when an
143
177
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -158,7 +192,8 @@ module Samagotchi
158
192
  parent_id: parent_id,
159
193
  messages: messages,
160
194
  delegate: delegate,
161
- llm_context: llm_context
195
+ llm_context: llm_context,
196
+ continues: continues
162
197
  )
163
198
  session.model_warning = model_warning
164
199
  # With no prompt there is no first turn to run (an attaching UI sends
@@ -174,10 +209,175 @@ module Samagotchi
174
209
  session.seed_images_dropped = dropped
175
210
  end
176
211
  setup_session_directory(session_dir, session, state_dir: sd)
212
+ SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
177
213
  spawn_worker_for_session(session, state_dir: sd)
178
214
  session
179
215
  end
180
216
 
217
+ # How long #continue_session waits for the previous link's worker to
218
+ # write its recap (IdleRecap's own request timeout).
219
+ CONTINUE_RECAP_WAIT = 30.0
220
+ # In the previous link's folder: one continue of a session at a time.
221
+ CONTINUE_LOCK = "continue.lock"
222
+ # How often a last:<id> continue looks again when another continue
223
+ # moved the chain's end while it waited for the lock.
224
+ CONTINUE_TRIES = 3
225
+
226
+ # Start the next link of a chain: a new session that continues
227
+ # +id_or_ref+ (Session#continues) in its folder, on its model (the name
228
+ # it was typed as, so an alias holds) and with its own llm_context,
229
+ # starting with a context note from chi: the link and the previous
230
+ # link's recap (SessionChain.note_text). The previous link is archived
231
+ # first, with its delegates (archive_session's rules: a turn running,
232
+ # a prompt queued or the step-limit question refuse; an idle worker is
233
+ # stopped), so a refusal starts nothing. Before that its live worker is
234
+ # asked for a recap and given up to +recap_wait+ seconds to write it.
235
+ # One continue of a session at a time (a lock in its folder): a double
236
+ # click finds the first one's link and is refused; a last:<id> one
237
+ # follows the chain to its new end instead.
238
+ # @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
239
+ # @param prompt [String, nil] its first message (nil: it starts idle)
240
+ # @param title [String, nil] what the lists show before the first turn;
241
+ # by default the prompt, else the previous link's preview
242
+ # @return [Session] the new link (#model_warning as spawn_session's)
243
+ # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
244
+ # @raise [ContinueRefused] continued already, delegates still open, or its folder is gone
245
+ # @raise [ArchiveRefused, OwnedByTUI] as archive_session
246
+ def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
247
+ archive_wait: 5)
248
+ sd = state_dir || Session.default_state_dir
249
+ CONTINUE_TRIES.times do |try|
250
+ id = SessionChain.resolve(id_or_ref, state_dir: sd)
251
+ result = with_continue_lock(id, sd) do
252
+ follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
253
+ next :moved if follow && SessionChain.next_of(id, state_dir: sd)
254
+
255
+ continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
256
+ archive_wait: archive_wait)
257
+ end
258
+ return result unless result == :moved
259
+ end
260
+ end
261
+
262
+ private_class_method def self.with_continue_lock(id, state_dir, &)
263
+ dir = Session.session_dir(id, state_dir: state_dir)
264
+ FileUtils.mkdir_p(dir)
265
+ File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
266
+ lock.flock(File::LOCK_EX)
267
+ yield
268
+ end
269
+ end
270
+
271
+ private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
272
+ previous = Session.load(id, state_dir: state_dir)
273
+ check_continue!(previous, state_dir)
274
+ # archive_session's refusals that can be known now, before the wait.
275
+ archivable!(id, state_dir)
276
+ recap_before_archive(id, state_dir, wait: recap_wait)
277
+ # Again: a turn sent to it during the wait may have delegated.
278
+ check_open_children!(id, state_dir)
279
+ already = [id, *descendant_ids(id, state_dir)].select do |sid|
280
+ ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
281
+ end
282
+ archived = archive_session(id, state_dir: state_dir, wait: archive_wait)[:archived] - already
283
+ begin
284
+ spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir)
285
+ rescue StandardError
286
+ # Nothing started: what this archived goes back to the lists, and
287
+ # a new link saved before its worker failed to spawn goes (left,
288
+ # it would hold the chain: "continued already", last: to it).
289
+ discard_failed_link(id, state_dir)
290
+ archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
291
+ raise
292
+ end
293
+ end
294
+
295
+ # The link a failed #continue_session saved (no other continues +id+:
296
+ # check_continue! made sure under the lock).
297
+ private_class_method def self.discard_failed_link(id, state_dir)
298
+ orphan = SessionChain.next_of(id, state_dir: state_dir) or return
299
+ delete_session(orphan, state_dir: state_dir)
300
+ rescue StandardError => e
301
+ Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
302
+ end
303
+
304
+ private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:)
305
+ recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
306
+ start = prompt.to_s.strip.empty? ? nil : prompt
307
+ title = previous.first_preview if title.to_s.strip.empty? && start.nil?
308
+ spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
309
+ model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
310
+ continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
311
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir)
312
+ end
313
+
314
+ # The refusals that come before anything changes: continued already (no
315
+ # forks in a chain), a delegate still open (its report would go to the
316
+ # archived link), the folder gone (the worker would run elsewhere), a
317
+ # model whose host config.yml no longer has.
318
+ # @raise [ContinueRefused, ModelProfile::MissingModel]
319
+ private_class_method def self.check_continue!(previous, state_dir)
320
+ if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
321
+ raise ContinueRefused.new(previous.id, :continued, ids: [following])
322
+ end
323
+
324
+ check_open_children!(previous.id, state_dir)
325
+ # Its model's host, as spawn_session checks it: before the archive
326
+ # stops the previous link's worker.
327
+ ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
328
+ dir = previous.working_directory.to_s
329
+ raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
330
+ end
331
+
332
+ # A child the next link couldn't follow once its parent is archived.
333
+ OpenChild = Data.define(:id, :short_id, :why)
334
+
335
+ # @raise [ContinueRefused] while #open_children has any
336
+ private_class_method def self.check_open_children!(id, state_dir)
337
+ open = open_children(id, state_dir)
338
+ return if open.empty?
339
+
340
+ raise ContinueRefused.new(id, :open_children, ids: open.map(&:id),
341
+ detail: open.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
342
+ end
343
+
344
+ # The unarchived children, at any depth (the archive's cascade), of the
345
+ # session still running, waiting for an answer, with a live worker, or
346
+ # (a delegate) with a reply its parent wasn't given (ChildrenStatus).
347
+ # @return [Array<OpenChild>]
348
+ private_class_method def self.open_children(id, state_dir)
349
+ require_relative "children_status"
350
+ [id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
351
+ .filter_map do |child|
352
+ why = if %w[waiting running].include?(child.state) then child.state
353
+ elsif child.live then "live"
354
+ elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
355
+ end
356
+ why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
357
+ end
358
+ end
359
+
360
+ # Ask the session's live worker for a recap now (Bridge POST /recap:
361
+ # IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
362
+ # to change. A worker that is gone wrote its recap when it left; one
363
+ # that has nothing new to say, too short a conversation, or recaps off
364
+ # answers at once. Best effort: the continue goes on without.
365
+ private_class_method def self.recap_before_archive(id, state_dir, wait:)
366
+ return unless worker_live?(id, state_dir: state_dir)
367
+
368
+ dir = Session.session_dir(id, state_dir: state_dir)
369
+ client = BridgeClient.discover(id, session_dir: dir) or return
370
+ before = RecapStore.read(dir)
371
+ reply = client.request_recap
372
+ asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
373
+ return unless %w[started in_flight].include?(asked)
374
+
375
+ BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
376
+ rescue StandardError => e
377
+ Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
378
+ nil
379
+ end
380
+
181
381
  # Build the opts hash passed to Process.spawn for a forked worker. The
182
382
  # child inherits this process's ENV and reads config.yml itself (as it
183
383
  # is when the worker starts); opts[:env] adds to it (merged, not
@@ -279,9 +479,11 @@ module Samagotchi
279
479
  # @param include_archived [Boolean] archived sessions too
280
480
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
281
481
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
282
- # parent_short_id:, delegate:, archived:, scratch:, test_run:, waiting:, waiting_id:, stopped_by:}; busy =
482
+ # parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
483
+ # waiting_id:, stopped_by:}; busy =
283
484
  # live with a turn running, delegate = started by the delegate tool
284
- # (Session#delegate?; a fork has a parent_id but isn't one), recap = the saved recap's first sentence, project =
485
+ # (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
486
+ # previous link of its chain, Session#continues), recap = the saved recap's first sentence, project =
285
487
  # Session#project_root, waiting = the kind of question it waits on
286
488
  # ("question", "approval", "hook"; Session#waiting_question) or nil,
287
489
  # waiting_id = that question's id or nil, relayed_to = the parent
@@ -310,7 +512,8 @@ module Samagotchi
310
512
  owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
311
513
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
312
514
  budget_tokens: -> { budgets.call(s) })&.round(1),
313
- parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), delegate: s.delegate?, archived: s.archived,
515
+ parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
516
+ continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
314
517
  scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
315
518
  end
316
519
  (limit ? summaries.first(limit) : summaries.to_a)
@@ -357,21 +560,29 @@ module Samagotchi
357
560
  def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
358
561
  sd = state_dir || Session.default_state_dir
359
562
  id = archive_target(id_or_prefix, sd)
360
- raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: sd).scratch
361
-
362
- tree = [id, *descendant_ids(id, sd)]
363
- owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
364
- tree.each do |sid|
365
- refusal = archive_refusal(sid, owners[sid], sd)
366
- raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
367
- end
368
-
563
+ tree, owners = archivable!(id, sd)
369
564
  stopped = tree.select { |sid| owners[sid] }
370
565
  stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
371
566
  archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
372
567
  { id: id, archived: archived, stopped: stopped, discarded: discarded }
373
568
  end
374
569
 
570
+ # The session and its descendants, with their owners, when they can be
571
+ # archived now (#archive_refusal for each), else the refusal.
572
+ # @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
573
+ # @raise [ArchiveRefused, OwnedByTUI]
574
+ private_class_method def self.archivable!(id, state_dir)
575
+ raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
576
+
577
+ tree = [id, *descendant_ids(id, state_dir)]
578
+ owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
579
+ tree.each do |sid|
580
+ refusal = archive_refusal(sid, owners[sid], state_dir)
581
+ raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
582
+ end
583
+ [tree, owners]
584
+ end
585
+
375
586
  # What keeps +sid+ (in an archive's tree) from being archived: :busy (a
376
587
  # turn runs), :queued (input/ holds a prompt, which outlives the stop and
377
588
  # runs on a resume, live worker or not), :offer (a live worker's continue
@@ -170,6 +170,7 @@ module Samagotchi
170
170
  # its git project's root, or nil
171
171
  # parent_id: the session that delegated it (the delegate tool), or nil
172
172
  # delegate: started by the delegate tool (a fork has a parent_id too)
173
+ # continues: the session it continues (its chain's previous link), or nil
173
174
  # archived: hidden from the lists (only with --archived can it be true)
174
175
  # scratch: a `chi scratch` session (deleted when its REPL ends, a
175
176
  # leftover one at the next sweep)
@@ -178,7 +179,7 @@ module Samagotchi
178
179
  # hook: chi answer or the web answers it), or nil; waiting_id: its
179
180
  # id, for chi answer --question; relayed_to: the parent session (short
180
181
  # id) whose card it also waits in (the approval relay), or nil
181
- keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id delegate archived scratch ctx_pct
182
+ keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
182
183
  waiting waiting_id relayed_to stopped_by]
183
184
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
184
185
  when "tsv"
@@ -199,6 +200,8 @@ module Samagotchi
199
200
  end
200
201
  # A delegated session points at its parent.
201
202
  child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
203
+ # A chain's link points at the one it continues.
204
+ child += " ↪ #{summary[:continues_short_id]}" if summary[:continues_short_id]
202
205
  flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
203
206
  stopped_by: summary[:stopped_by])
204
207
  ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
@@ -244,6 +247,7 @@ module Samagotchi
244
247
  row = lambda do |s|
245
248
  flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
246
249
  child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
250
+ child += " ↪ #{s.continues[0, 8]}" if s.continues
247
251
  # A question waits for an answer (chi answer, the web, chi --attach).
248
252
  live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
249
253
  waiting = s.waiting_question(live: !!live)
@@ -96,17 +96,29 @@ module Samagotchi
96
96
  paint(TurnNote.empty_answer_line(retries), 90)
97
97
  end
98
98
 
99
- # "retrying (1/3 in 0.5s): Errno::ECONNREFUSED": the retry of all the
100
- # retries there will be (as the web counts), the wait before it, and
101
- # what failed (generation_retrying).
99
+ # "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
100
+ # is asked again (generation_retrying), worded as the web words it
101
+ # (turn_events.js retryStatusLine; spec/shared/labels_matrix.json):
102
+ # why (the HTTP status, the error's short class name, or "stream
103
+ # dropped" for a dropped stream's step asked again), the wait before
104
+ # it, and the retry of all the retries there will be.
102
105
  def format_generation_retry_line(event)
103
- return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
106
+ why = event[:restarted] ? "stream dropped" : retry_cause(event)
107
+ delay = Float(event[:next_delay], exception: false)
108
+ text = +"\u21bb retrying"
109
+ text << " (#{why})" unless why.empty?
110
+ text << " in #{format("%.1f", (delay * 10).round / 10.0)}s" if delay&.finite?
111
+ text << ", #{event[:attempt]}#{"/#{event[:max_retries]}" if event[:max_retries]}" if event[:attempt]
112
+ text
113
+ end
104
114
 
105
- text = "retrying (#{event[:attempt]}/#{event[:max_retries]} in #{format("%.1f", event[:next_delay].to_f)}s)"
106
- # restarted: a dropped stream's step asked again (the chat loop's).
107
- return "#{text}: stream dropped" if event[:restarted]
115
+ # What failed, for a retry line: the HTTP status, else the error's
116
+ # class name without its namespace (Errno::ECONNREFUSED: ECONNREFUSED).
117
+ def retry_cause(event)
118
+ status = event[:status]
119
+ return status.to_s unless status.nil? || status.to_s.empty? || status.to_s == "0"
108
120
 
109
- event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
121
+ event[:error_class].to_s.split("::").last.to_s
110
122
  end
111
123
 
112
124
  # " → image 1280×800" after a tool line whose tool read an image.
@@ -356,13 +368,16 @@ module Samagotchi
356
368
  end
357
369
 
358
370
  # /stats' cost: the reported part and the estimated one, each when present.
359
- def stats_cost_text(tokens)
371
+ # +price_host+ names the host whose hosts.<name>.models prices the
372
+ # estimate came from (Engine#stats_snapshot; nil from an older worker).
373
+ def stats_cost_text(tokens, price_host: nil)
360
374
  reported = cost_text(tokens[:cost_sum])
361
375
  estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
362
376
  return nil if reported.empty? && estimated.empty?
363
377
  return "#{reported} (this session only)" if estimated.empty?
364
378
 
365
- estimate = "#{estimated} from hosts.<name>.models prices"
379
+ source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
380
+ estimate = "#{estimated} from #{source}"
366
381
  "#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
367
382
  end
368
383
 
@@ -492,7 +507,7 @@ module Samagotchi
492
507
  "#{token_breakdown_text(tokens)}"
493
508
  speed = stats_speed_text(tokens)
494
509
  lines << "speed: #{speed}" if speed
495
- cost = stats_cost_text(tokens)
510
+ cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
496
511
  lines << "cost: #{cost}" if cost
497
512
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
498
513
  lines << "cancellations: #{snapshot[:cancellations]}"
@@ -1351,9 +1351,11 @@ module Samagotchi
1351
1351
  # question there): the question is dismissed at once, as an empty answer
1352
1352
  # would be, and the model finishes its reply. Never a read of stdin: a
1353
1353
  # pipe its writer keeps open would block it forever. Its one line goes to
1354
- # stderr (#banner): stdout is the answer's alone.
1354
+ # stderr (#banner): stdout is the answer's alone. The model is told no
1355
+ # one could answer (QuestionDesk::UNANSWERABLE_NOTE), not that the user
1356
+ # dismissed it.
1355
1357
  def dismiss_unaskable_question(prompt)
1356
- @engine.cancel_question("user") rescue nil
1358
+ @engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
1357
1359
  banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
1358
1360
  false
1359
1361
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.46.0"
4
+ VERSION = "0.47.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end