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
@@ -945,6 +945,8 @@ module Samagotchi
945
945
  # context window, the served model and (on a native host) the prompt
946
946
  # profile come from the effective model's server, so this may make one
947
947
  # short /props GET; the cheap #session_state_snapshot never does.
948
+ # price_host: the effective model's host, whose hosts.<name>.models
949
+ # prices the cost estimate names.
948
950
  # @return [Hash] @metrics.snapshot, filled in
949
951
  def stats_snapshot
950
952
  snapshot = @metrics.snapshot
@@ -952,7 +954,7 @@ module Samagotchi
952
954
  served, served_for = served_model_for(snapshot, target: target)
953
955
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
954
956
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
955
- prompt_notes: prompt_notes.map(&:to_h))
957
+ prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
956
958
  unless snapshot.dig(:context, :window_tokens)
957
959
  window = current_context_window(target)
958
960
  if window
@@ -1051,8 +1053,7 @@ module Samagotchi
1051
1053
  # warn about. nil when none does.
1052
1054
  # @return [String, nil]
1053
1055
  def served_expected_by(served, target: nil)
1054
- target ||= @host_registry.resolve(@effective_model_name)
1055
- target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
1056
+ ServedModel.expected_by(target || @host_registry.resolve(@effective_model_name), served)
1056
1057
  rescue StandardError
1057
1058
  nil
1058
1059
  end
@@ -1447,8 +1448,6 @@ module Samagotchi
1447
1448
  @question_desk.request(payload)
1448
1449
  end
1449
1450
 
1450
- QUESTION_DISMISSED_NOTE = QuestionDesk::DISMISSED_NOTE
1451
-
1452
1451
  # Open a question for the UIs and wait for its answer
1453
1452
  # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
1454
1453
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
@@ -78,18 +78,22 @@ module Samagotchi
78
78
  # One host's rows as the listings show them, declared ids first, then
79
79
  # the listed ones in the host's order (see Row). An id both declared and
80
80
  # listed is one row. Pure: +results+ (list_all_models') is only read.
81
- # A host that errored has no rows: its declared ids aren't shown
82
- # either (the listings say it's unreachable).
81
+ # A host that errored keeps only its declared ids, marked unavailable
82
+ # (still pickable; the listings warn it's unreachable); [] when it
83
+ # declares none.
83
84
  # @param results [Hash{String => Hash}] host name => {models: [ModelInfo], error:}
84
85
  # @param entries [Hash{String => HostRegistry::HostEntry}]
85
- # @return [Hash{String => Array<Row>}] for the hosts that answered
86
+ # @return [Hash{String => Array<Row>}] for every host in +results+
86
87
  def self.rows(results, entries)
87
88
  results.each_with_object({}) do |(name, data), out|
88
- next if data[:error]
89
+ declared = entries[name]&.models || {}
90
+ if data[:error]
91
+ out[name] = declared.values.map { |m| HostModel::Row.new(id: m.id, configured: true, info: nil, unavailable: true) }
92
+ next
93
+ end
89
94
 
90
95
  infos = Array(data[:models])
91
96
  by_id = infos.group_by { |info| info.id.to_s.downcase }
92
- declared = entries[name]&.models || {}
93
97
  out[name] = declared.values.map do |m|
94
98
  info = by_id[m.id.downcase]&.first
95
99
  # A listed id keeps the host's spelling: a case-sensitive server takes only that.
@@ -111,6 +115,9 @@ module Samagotchi
111
115
  # A listing row (HostModel.rows): id as the host lists it, else as
112
116
  # declared;
113
117
  # configured: declared under hosts.<name>.models; info: the host's
114
- # ModelInfo for it, nil when only declared.
115
- HostModel::Row = Data.define(:id, :configured, :info)
118
+ # ModelInfo for it, nil when only declared; unavailable: the host failed
119
+ # to list its models; only its declared ids are shown.
120
+ HostModel::Row = Data.define(:id, :configured, :info, :unavailable) do
121
+ def initialize(id:, configured:, info:, unavailable: false) = super
122
+ end
116
123
  end
@@ -322,7 +322,7 @@ module Samagotchi
322
322
  save_model_list(name, models)
323
323
  rescue StandardError => e
324
324
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
325
- data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
325
+ data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: list_error(e) }
326
326
  end
327
327
  results_mutex.synchronize { results[name] = data }
328
328
  end
@@ -374,6 +374,11 @@ module Samagotchi
374
374
 
375
375
  private
376
376
 
377
+ # A listing error as text without the host's name (a provider error's
378
+ # #detail: "connection refused", "request failed after …"): every line
379
+ # that shows it names the host itself.
380
+ def list_error(error) = error.is_a?(LLM::ProviderError) ? error.detail : error.message
381
+
377
382
  # Save a host's just-listed models on disk, for the processes that spawn
378
383
  # a worker without listing (`chi send --new --model`, delegate):
379
384
  # ModelListStore is what they check an id against.
@@ -510,15 +510,18 @@ module Samagotchi
510
510
  generation_controller = nil
511
511
  response = with_generation do |controller|
512
512
  generation_controller = controller
513
+ # One count for the step: the ask-again without thinking fields
514
+ # doesn't get drops of its own.
515
+ drops = DropCount.new(0, 0)
513
516
  begin
514
- request_with_drops(iteration, retry_generation, streamed, thought, 0, controller)
517
+ request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
515
518
  rescue BadRequest => e
516
519
  raise unless thinking_refused?(e)
517
520
 
518
521
  # Once per model: asked again without the thinking fields.
519
522
  @loop.thinking_refused!(@model_name)
520
523
  emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
521
- request_with_drops(iteration, retry_generation, streamed, thought, 0, controller)
524
+ request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
522
525
  end
523
526
  rescue RequestCancelled
524
527
  raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
@@ -596,9 +599,14 @@ module Samagotchi
596
599
  # retries.
597
600
  MAX_STREAM_DROPS = 2
598
601
 
599
- # Runs one request, asking again on a mid-stream drop. +attempt+
600
- # counts the drops already retried this step.
601
- def request_with_drops(iteration, retry_generation, streamed, thought, attempt, generation_controller)
602
+ # A step's drops asked again (+drops+) and the HTTP requests made so
603
+ # far (+requests+: each failed request's transport attempts), so the
604
+ # error that ends the step counts every request, not the last one's.
605
+ DropCount = Struct.new(:drops, :requests)
606
+
607
+ # Runs one request, asking again on a mid-stream drop; +count+ (a
608
+ # DropCount) carries on across calls for one step.
609
+ def request_with_drops(iteration, retry_generation, streamed, thought, count, generation_controller)
602
610
  streamed_any = false
603
611
  request(iteration, retry_generation, streamed, thought, generation_controller, -> { streamed_any = true })
604
612
  rescue RetryExhausted => e
@@ -607,10 +615,16 @@ module Samagotchi
607
615
  cancelled = [@cancel_controller, generation_controller].compact.find(&:cancelled?)
608
616
  raise RequestCancelled, cancelled.reason if cancelled
609
617
 
618
+ count.requests += e.attempts.to_i
610
619
  limit = @loop.stream_drop_limit(MAX_STREAM_DROPS)
611
- raise if attempt >= limit || !streamed_any
620
+ if count.drops >= limit || !streamed_any
621
+ raise if count.requests == e.attempts.to_i
622
+
623
+ raise RetryExhausted.new(attempts: count.requests, last_error: e.last_error, label: e.host)
624
+ end
612
625
 
613
- attempt += 1
626
+ count.drops += 1
627
+ attempt = count.drops
614
628
  streamed.clear
615
629
  thought.clear
616
630
  delay = @loop.generation_retry_delay(attempt)
@@ -620,6 +634,12 @@ module Samagotchi
620
634
  next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
621
635
  @loop.wait_retry(delay, @cancel_controller, generation_controller)
622
636
  retry
637
+ rescue ProviderError => e
638
+ # Another failure (a 503, a refusal) after earlier requests this
639
+ # step: its count takes theirs in.
640
+ count.requests += e.attempts.to_i
641
+ e.attempts = count.requests
642
+ raise
623
643
  end
624
644
 
625
645
  # A 400 about reasoning, for a request that carried thinking fields
@@ -223,11 +223,17 @@ module Samagotchi
223
223
 
224
224
  def initialize(attempts:, last_error:, label: "llama.cpp")
225
225
  @last_error = last_error
226
- super("#{label} request failed after #{attempts} attempts: #{last_error.class}: #{last_error.message}",
226
+ super("#{label} request failed after #{self.class.count(attempts)}: #{last_error.class}: #{last_error.message}",
227
227
  host: label, retryable: false, attempts: attempts)
228
228
  end
229
229
 
230
- def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
230
+ def summary = "network error after #{self.class.count(attempts)} (host #{host}: #{last_error.class})"
231
+
232
+ # The message without its leading "host ": "request failed after …".
233
+ def detail = host ? message.delete_prefix("#{host} ") : message
234
+
235
+ # "1 attempt", "3 attempts".
236
+ def self.count(attempts) = "#{attempts} attempt#{"s" unless attempts == 1}"
231
237
  end
232
238
 
233
239
  # A refused connection: nothing listens at the host's address. Not
@@ -181,6 +181,7 @@ module Samagotchi
181
181
  return nil if session.nil?
182
182
 
183
183
  require_relative "config"
184
+ require_relative "model_profile"
184
185
  registry ||= begin
185
186
  require_relative "host_registry"
186
187
  HostRegistry.new
@@ -9,13 +9,14 @@ module Samagotchi
9
9
  module ModelCatalog
10
10
  # host: the host that listed (or declares) the id; ref: the name
11
11
  # `--model` takes for it (see .spell); configured: declared under
12
- # hosts.<name>.models (HostModel.rows).
13
- Row = Data.define(:host, :id, :ref, :configured) do
14
- def initialize(host:, id:, ref:, configured: false) = super
12
+ # hosts.<name>.models (HostModel.rows); unavailable: a declared id of a
13
+ # host that failed to list its models (still pickable).
14
+ Row = Data.define(:host, :id, :ref, :configured, :unavailable) do
15
+ def initialize(host:, id:, ref:, configured: false, unavailable: false) = super
15
16
  end
16
17
  # rows: the default host's first, then the others by name, each host's
17
- # declared ids first (HostModel.rows); warnings:
18
- # "host: error" per host that failed.
18
+ # declared ids first (HostModel.rows; a host that failed keeps only
19
+ # those); warnings: "host: error" per host that failed.
19
20
  Listing = Data.define(:rows, :warnings)
20
21
 
21
22
  module_function
@@ -29,15 +30,13 @@ module Samagotchi
29
30
  warnings = []
30
31
  host_rows = HostModel.rows(results, registry.entries)
31
32
  results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
32
- if results[host][:error]
33
- warnings << "#{host}: #{results[host][:error]}"
34
- next
35
- end
36
- host_rows[host].each do |row|
33
+ warnings << "#{host}: #{results[host][:error]}" if results[host][:error]
34
+ host_rows.fetch(host, []).each do |row|
37
35
  id = row.id
38
36
  next if id.strip.empty? || (!row.configured && id.end_with?(":batch"))
39
37
 
40
- rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured)
38
+ rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured,
39
+ unavailable: row.unavailable)
41
40
  end
42
41
  end
43
42
  Listing.new(rows: rows, warnings: warnings)
@@ -55,7 +54,8 @@ module Samagotchi
55
54
  # go first (its rows are the "default host" group). default: the default's listed
56
55
  # row name when it resolves to one, else its resolved ref; default_typed:
57
56
  # the alias it was typed as. A row whose name resolves elsewhere (an
58
- # alias named like the id shadows it) carries shadowed_by.
57
+ # alias named like the id shadows it) carries shadowed_by; a declared id
58
+ # of a host that failed to list carries unavailable.
59
59
  # @param default_name [String, nil] default.model as configured
60
60
  # @return [Hash]
61
61
  def payload(results, registry:, default_name:)
@@ -63,6 +63,7 @@ module Samagotchi
63
63
  models = listing.rows.map do |row|
64
64
  hash = { name: row.ref, host: row.host, id: row.id }
65
65
  hash[:configured] = true if row.configured
66
+ hash[:unavailable] = true if row.unavailable
66
67
  shadow = shadowed_by(row, registry)
67
68
  shadow ? hash.merge(shadowed_by: shadow) : hash
68
69
  end
@@ -232,10 +232,11 @@ module Samagotchi
232
232
  return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
233
233
 
234
234
  fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
235
- # A re-list that answered: the saved list catches up (the default
236
- # re-list, HostRegistry#list_models, saved it already).
235
+ # A re-list that answered: the saved list catches up. The default
236
+ # re-list, HostRegistry#list_models, saved it already; a given one
237
+ # is saved here.
237
238
  if fresh_ids
238
- Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
239
+ Samagotchi::ModelListStore.save(host, fresh_ids, env: env) if relist
239
240
  return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
240
241
 
241
242
  return unknown_model_message(parsed.id, host, fresh_ids)
@@ -23,13 +23,14 @@ module Samagotchi
23
23
  Lists the models every configured host offers, as the names
24
24
  --model takes: the default first, a default-host id bare, another
25
25
  host's as host:id, then the aliases as "name -> ref". A host that
26
- fails or doesn't answer in time is noted on stderr.
26
+ fails or doesn't answer in time is noted on stderr; the ids
27
+ declared under its hosts.<name>.models are still listed.
27
28
  TEXT only the names containing it (any case)
28
29
  --format json the default, the models (host, id, name), the aliases
29
30
  and the warnings as one JSON object
30
31
  --timeout S wait at most S seconds for the hosts; default 4
31
32
  Exit 0 when any host listed its models, 1 when none did (the
32
- default is still printed), 2 on a usage error.
33
+ default and declared names still print), 2 on a usage error.
33
34
  TEXT
34
35
 
35
36
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
@@ -59,8 +60,10 @@ module Samagotchi
59
60
  return usage_error("--timeout takes a number of seconds above 0") unless timeout&.positive?
60
61
 
61
62
  payload, listed = catalog(timeout)
63
+ # Before the filter: a down host's declared ids are listed even when TEXT hides them.
64
+ down_hosts = payload[:models].select { |m| m[:unavailable] }.map { |m| m[:host] }.uniq
62
65
  payload = filtered(payload, parsed.args.join(" ").strip)
63
- options[:format] == "json" ? print_json(payload) : print_text(payload)
66
+ options[:format] == "json" ? print_json(payload) : print_text(payload, down_hosts)
64
67
  listed ? 0 : 1
65
68
  end
66
69
 
@@ -99,14 +102,18 @@ module Samagotchi
99
102
  @stdout.puts(JSON.generate(payload.except(:filter)))
100
103
  end
101
104
 
102
- def print_text(payload)
105
+ # @param down_hosts [Array<String>] hosts that failed but declare ids
106
+ def print_text(payload, down_hosts)
103
107
  default = payload[:default]
104
108
  names = payload[:models].reject { |m| m[:shadowed_by] }.map { |m| m[:name] }
105
109
  shown_default = default && (payload[:filter].nil? || default.downcase.include?(payload[:filter].downcase))
106
110
  @stdout.puts(default) if shown_default
107
111
  (names - [default]).each { |name| @stdout.puts(name) }
108
112
  payload[:aliases].each { |a| @stdout.puts("#{a[:name]} -> #{a[:ref]}") }
109
- payload[:warnings].each { |w| @stderr.puts("chi models: #{w}") }
113
+ payload[:warnings].each do |w|
114
+ kept = down_hosts.any? { |host| w.start_with?("#{host}: ") }
115
+ @stderr.puts("chi models: #{w}#{" (its declared ids are still listed)" if kept}")
116
+ end
110
117
  end
111
118
  end
112
119
  end
@@ -64,6 +64,13 @@ module Samagotchi
64
64
  "or anything else that changes files or state. Finish your reply with what you found " \
65
65
  "and what you would do, and wait."
66
66
 
67
+ # The cancel reason of a question no one could answer: a
68
+ # --non-interactive run has no one to ask (TerminalUI).
69
+ UNANSWERABLE_REASON = "non_interactive"
70
+ UNANSWERABLE_NOTE = "No one could answer the question: this is a non-interactive run. Don't do what you " \
71
+ "asked about, or anything else that changes files or state. Finish your reply with " \
72
+ "what you found and what you would do."
73
+
67
74
  # @param session [#call] → Session, nil
68
75
  # @param state_dir [#call] → String, the session's state dir
69
76
  # @param emit [#call] (event) → emits an event to the turn sink + observers
@@ -82,6 +89,8 @@ module Samagotchi
82
89
  @cv = @lock.new_cond
83
90
  @pending = nil
84
91
  @answer = nil
92
+ # Why the open question was cancelled (#cancel), for the sync path.
93
+ @cancel_reason = nil
85
94
  @sync_handler = nil
86
95
  # The standing question while it is @pending: {id:, fields:,
87
96
  # on_answer:, on_superseded_close:}; @shelved, one superseded by (or
@@ -106,7 +115,10 @@ module Samagotchi
106
115
  .merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
107
116
  # Dismissed (the card's dismiss, Esc): an answer of its own, not a
108
117
  # tool failure the model learns to avoid the tool from.
109
- result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
118
+ if result.is_a?(Hash) && result[:error] == "no answer"
119
+ note = result[:reason] == UNANSWERABLE_REASON ? UNANSWERABLE_NOTE : DISMISSED_NOTE
120
+ result = { dismissed: true, id: result[:id], note: note }
121
+ end
110
122
  # Who answered is for the relay, not the model's tool result.
111
123
  result = result.except(:by) if result.is_a?(Hash)
112
124
  result.is_a?(String) ? result : JSON.generate(result)
@@ -213,7 +225,15 @@ module Samagotchi
213
225
  emit({ type: :question_answered, id: id, answer: ans })
214
226
  return ans
215
227
  end
216
- return sync_res if sync_res.is_a?(String) && !sync_res.strip.empty?
228
+ if sync_res.is_a?(String) && !sync_res.strip.empty?
229
+ # The handler's text is the answer: nothing stays pending, and
230
+ # every UI hears it was answered (the text as its freeform).
231
+ @lock.synchronize { @pending = nil }
232
+ clear_saved_question
233
+ emit({ type: :question_answered, id: id,
234
+ answer: { id: id.to_s, selected: [], freeform: sync_res.strip, selected_indices: [] } })
235
+ return sync_res
236
+ end
217
237
  rescue StandardError => e
218
238
  Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
219
239
  end
@@ -221,9 +241,12 @@ module Samagotchi
221
241
  # CV (no cross-thread answerer exists for synchronous UIs). Clear pending
222
242
  # and return an error so the model can fallback to plain text. Generic
223
243
  # observers will discard the stale question_requested via staleness check.
224
- @lock.synchronize { @pending = nil }
244
+ reason = @lock.synchronize do
245
+ @pending = nil
246
+ @cancel_reason
247
+ end
225
248
  clear_saved_question
226
- return { error: "no answer", detail: "handler failed to capture selection", id: id }
249
+ return { error: "no answer", detail: "handler failed to capture selection", id: id, reason: reason }.compact
227
250
  end
228
251
 
229
252
  # Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
@@ -356,6 +379,7 @@ module Samagotchi
356
379
  next if @standing
357
380
 
358
381
  pending[:status] = "cancelled"
382
+ @cancel_reason = reason.to_s
359
383
  @cv.broadcast
360
384
  pending[:id]
361
385
  end
@@ -403,6 +427,7 @@ module Samagotchi
403
427
  @lock.synchronize do
404
428
  @pending = pending
405
429
  @answer = nil
430
+ @cancel_reason = nil
406
431
  @standing = standing&.merge(id: pending[:id])
407
432
  end
408
433
  if session
@@ -4,6 +4,7 @@ require_relative "client_id"
4
4
  require_relative "session"
5
5
  require_relative "session_inbox"
6
6
  require_relative "session_manager"
7
+ require_relative "session_chain"
7
8
  require_relative "context_quote"
8
9
  require_relative "reply_wait"
9
10
  require_relative "parent_report"
@@ -34,6 +35,7 @@ module Samagotchi
34
35
  USAGE = <<~TEXT
35
36
  Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
36
37
  chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
38
+ chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
37
39
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
38
40
  chi send --wait [--timeout S] [--format json] ID
39
41
  Sends a message to each session, as if typed in it: a turn starts,
@@ -52,6 +54,14 @@ module Samagotchi
52
54
  web does, and print its id
53
55
  --dir DIR (--new) its folder, the project it belongs to; default
54
56
  the current one
57
+ --continues ID
58
+ (--new) start the next link of that session's chain:
59
+ in its folder, on its model and LLM context, with a
60
+ note carrying its recap; it is archived. last:ID
61
+ names the chain's latest link. Without -m the new
62
+ session waits idle. Refused for a session continued
63
+ already (naming the next link) or with delegates
64
+ still open
55
65
  --model M (--new) its model; default the configured one
56
66
  --llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
57
67
  (--new) its own LLM context strategy (none, stale,forget),
@@ -83,6 +93,7 @@ module Samagotchi
83
93
  f.value "-m", "--message"
84
94
  f.switch "--new"
85
95
  f.value "--dir"
96
+ f.value "--continues"
86
97
  f.value "--model"
87
98
  f.value "--llm-context", key: :llm_strategy
88
99
  f.value "--llm-context-apply", key: :llm_apply
@@ -152,10 +163,14 @@ module Samagotchi
152
163
  # Before the wait-only case, which would drop the images.
153
164
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
154
165
  return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
155
- return usage_error("no message: pass -m TEXT or pipe it in") unless prompt
166
+
167
+ # The next link of a chain may start idle, waiting for its first message.
168
+ idle_link = prompt.nil? && options[:continues]
169
+ return usage_error("--wait needs a message to wait for: pass -m TEXT") if idle_link && options[:wait]
170
+ return usage_error("no message: pass -m TEXT or pipe it in") unless prompt || idle_link
156
171
 
157
172
  begin
158
- prompt = SessionInbox.checked_text(prompt, noun: "message")
173
+ prompt &&= SessionInbox.checked_text(prompt, noun: "message")
159
174
  rescue SessionInbox::NoteRejected => e
160
175
  @stderr.puts("chi send: #{e.message}")
161
176
  return 1
@@ -205,7 +220,7 @@ module Samagotchi
205
220
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
206
221
  return new_options(options) if options[:new]
207
222
 
208
- %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
223
+ %i[dir model continues].each { |key| return usage_error("--#{key} needs --new") if options[key] }
209
224
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
210
225
  return usage_error("give session ids") if options[:ids].empty?
211
226
 
@@ -220,6 +235,15 @@ module Samagotchi
220
235
  def new_options(options)
221
236
  return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
222
237
 
238
+ if options[:continues]
239
+ taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
240
+ LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] }
241
+ unless taken.compact.empty?
242
+ return usage_error("--continues takes the previous session's folder, model and LLM context; " \
243
+ "leave out #{taken.compact.join(", ")}")
244
+ end
245
+ end
246
+
223
247
  words = LLM_CONTEXT_FIELDS.filter_map { |key, field| [field, options[key]] if options[key] }.to_h
224
248
  begin
225
249
  options[:llm_context] = LLMContextOverride.update(nil, words) unless words.empty?
@@ -253,7 +277,10 @@ module Samagotchi
253
277
  # @return [Integer] the exit status
254
278
  def run_new(prompt, options)
255
279
  images = !@images.empty?
256
- if images && (refusal = vision_refusal(options[:model]))
280
+ model, typed = continued_model(options)
281
+ return 1 if model == false
282
+
283
+ if images && (refusal = vision_refusal(model, typed: typed))
257
284
  error_line("chi send: refused: #{refusal}")
258
285
  return 1
259
286
  end
@@ -264,9 +291,16 @@ module Samagotchi
264
291
  through_worker = images || command_like?(prompt)
265
292
  begin
266
293
  start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
267
- session = SessionManager.spawn_session(**start, working_directory: options[:dir],
268
- model_name: options[:model], llm_context: options[:llm_context],
269
- state_dir: @state_dir)
294
+ session = if options[:continues]
295
+ SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
296
+ else
297
+ SessionManager.spawn_session(**start, working_directory: options[:dir],
298
+ model_name: options[:model], llm_context: options[:llm_context],
299
+ state_dir: @state_dir)
300
+ end
301
+ rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
302
+ error_line("chi send: refused: #{e.message}")
303
+ return 1
270
304
  rescue StandardError => e
271
305
  error_line("chi send: could not start a session: #{e.message}")
272
306
  return 1
@@ -275,7 +309,7 @@ module Samagotchi
275
309
  if through_worker
276
310
  return 1 unless deliver_new(session, prompt)
277
311
  else
278
- @info.puts("#{session.id} started")
312
+ @info.puts("#{session.id} started#{continued_words(session)}")
279
313
  end
280
314
  @session_id = session.id
281
315
  return 0 unless options[:wait]
@@ -285,6 +319,22 @@ module Samagotchi
285
319
  timeout: options[:timeout])
286
320
  end
287
321
 
322
+ # --continues: the model the new link will run (the previous link's),
323
+ # for the images check; [nil, nil] without --continues.
324
+ # @return [Array, false] [model_name, model_typed], or false after the
325
+ # error line (no such session)
326
+ def continued_model(options)
327
+ return [options[:model], nil] unless options[:continues]
328
+
329
+ previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
330
+ [previous.model_name, previous.model_typed]
331
+ rescue ArgumentError => e
332
+ error_line("chi send: #{e.message}")
333
+ false
334
+ end
335
+
336
+ def continued_words(session) = session.continues ? " (continues #{session.continues[0, 8]})" : ""
337
+
288
338
  # One existing session, then its next reply. The cursor and baseline
289
339
  # are taken before the message goes in, so neither an older reply nor a
290
340
  # turn that ends before the first look is mistaken for the answer. A
@@ -371,7 +421,7 @@ module Samagotchi
371
421
  end
372
422
 
373
423
  @sent_command = command_ack?(result[:ack])
374
- @info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
424
+ @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
375
425
  true
376
426
  end
377
427
 
@@ -17,6 +17,15 @@ module Samagotchi
17
17
  name.is_a?(String) && !name.strip.empty? ? name : nil
18
18
  end
19
19
 
20
+ # The host whose hosts.<name>.models.<id>.served names +served+ for
21
+ # +target+'s model (a gateway's round-robin targets): not a mismatch to
22
+ # warn about. nil when none does.
23
+ # @param target [HostRegistry::ModelTarget]
24
+ # @return [String, nil]
25
+ def expected_by(target, served)
26
+ target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
27
+ end
28
+
20
29
  # The separators a provider uses when it decorates a model name: a tag
21
30
  # (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
22
31
  # owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
@@ -72,6 +72,11 @@ module Samagotchi
72
72
  # Started by the `delegate` tool (a fork has a parent_id too, but isn't
73
73
  # one): see #delegate?.
74
74
  attr_writer :delegate
75
+ # The session this one continues (SessionManager.continue_session): the
76
+ # previous link of a chain, "the next day of the same routine", else
77
+ # nil. Not a parent: archiving one link leaves the next alone. The chain
78
+ # itself is derived (SessionChain), never stored.
79
+ attr_accessor :continues
75
80
  # A `chi scratch` session: deleted when its REPL ends, and by the next
76
81
  # sweep (or `chi sessions clean`) when the process died first.
77
82
  attr_accessor :scratch
@@ -130,7 +135,8 @@ module Samagotchi
130
135
  first_preview: "", test_run: false, pending_question: nil,
131
136
  used_memory_names: [], project_root: nil,
132
137
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
133
- last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [])
138
+ last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
139
+ continues: nil)
134
140
  @id = id
135
141
  @metadata_version = metadata_version
136
142
  @mode = mode
@@ -154,6 +160,7 @@ module Samagotchi
154
160
  @scratch = !!scratch
155
161
  @last_turn = last_turn
156
162
  @delegate = !!delegate
163
+ @continues = continues&.to_s
157
164
  self.llm_context = llm_context
158
165
  @archived = false
159
166
  end
@@ -205,7 +212,7 @@ module Samagotchi
205
212
  # seed); [] by default
206
213
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
207
214
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
208
- scratch: false, model_typed: nil, delegate: false, llm_context: nil)
215
+ scratch: false, model_typed: nil, delegate: false, llm_context: nil, continues: nil)
209
216
  now = Time.now.iso8601(3)
210
217
  resolved_test = if test_run.nil?
211
218
  test_session_env?
@@ -230,7 +237,8 @@ module Samagotchi
230
237
  parent_id: parent_id,
231
238
  scratch: scratch,
232
239
  delegate: delegate,
233
- llm_context: llm_context
240
+ llm_context: llm_context,
241
+ continues: continues
234
242
  )
235
243
  end
236
244
 
@@ -287,7 +295,8 @@ module Samagotchi
287
295
  "scratch" => false,
288
296
  "last_turn" => nil,
289
297
  "delegate" => false,
290
- "llm_context" => nil
298
+ "llm_context" => nil,
299
+ "continues" => nil
291
300
  }.freeze
292
301
 
293
302
  # A session from a parsed session file. +messages+ false leaves the