samagotchi 0.11.0 → 0.12.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +25 -1
  6. data/docs/desktop.md +24 -2
  7. data/lib/samagotchi/bridge.rb +4 -4
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  10. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  11. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  12. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  13. data/lib/samagotchi/engine.rb +445 -482
  14. data/lib/samagotchi/host_registry.rb +26 -2
  15. data/lib/samagotchi/idle_reminders.rb +13 -36
  16. data/lib/samagotchi/kernel_loop.rb +82 -120
  17. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  18. data/lib/samagotchi/llm/model_result.rb +14 -20
  19. data/lib/samagotchi/llm/native_backend.rb +3 -21
  20. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  21. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  22. data/lib/samagotchi/llm/usage.rb +2 -57
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  24. data/lib/samagotchi/model_catalog.rb +101 -0
  25. data/lib/samagotchi/model_profile.rb +1 -1
  26. data/lib/samagotchi/models_command.rb +112 -0
  27. data/lib/samagotchi/reminder_queue.rb +73 -0
  28. data/lib/samagotchi/reminder_store.rb +3 -3
  29. data/lib/samagotchi/self_report.rb +8 -1
  30. data/lib/samagotchi/send_command.rb +1 -1
  31. data/lib/samagotchi/session_commands.rb +5 -5
  32. data/lib/samagotchi/session_manager.rb +2 -6
  33. data/lib/samagotchi/terminal_ui.rb +8 -28
  34. data/lib/samagotchi/thinking.rb +1 -1
  35. data/lib/samagotchi/tool_runner.rb +4 -6
  36. data/lib/samagotchi/tools/builtins.rb +1 -1
  37. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  38. data/lib/samagotchi/turn_flow.rb +4 -4
  39. data/lib/samagotchi/turn_state.rb +126 -0
  40. data/lib/samagotchi/used_memories.rb +88 -0
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/vision_support.rb +4 -4
  43. data/lib/samagotchi/web/app.rb +14 -33
  44. data/lib/samagotchi/web/session_hub.rb +0 -4
  45. data/lib/samagotchi/web/session_summary.rb +6 -6
  46. data/lib/samagotchi/worker.rb +1 -1
  47. metadata +8 -1
@@ -12,7 +12,6 @@ require_relative "context_note"
12
12
  require_relative "cancellation_controller"
13
13
  require_relative "llm/errors"
14
14
  require_relative "host_registry"
15
- require_relative "kernel_loop"
16
15
  require_relative "session"
17
16
  require_relative "owner_lock"
18
17
  require_relative "engine"
@@ -114,9 +113,6 @@ module Samagotchi
114
113
  # The model it starts on names a configured host (a bad default.model
115
114
  # doesn't matter when --model or the session picks another).
116
115
  ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
117
- # Only a caller's profile goes in; otherwise the Engine resolves one
118
- # for the effective model and hands it to the kernel.
119
- @kernel = KernelLoop.new(client: @client, profile: profile, reminder_store: Samagotchi::ReminderStore.new)
120
116
  @no_default_input = no_default_input
121
117
  @non_interactive = non_interactive
122
118
  # --memory and --mute, with a resumed session's own lists first: a
@@ -157,7 +153,6 @@ module Samagotchi
157
153
  model_name: nil,
158
154
  memories: @requested_memories,
159
155
  muted_memories: @muted_memory_names,
160
- kernel: @kernel,
161
156
  recap: scratch ? nil : recap_config,
162
157
  scratch: scratch,
163
158
  reminders: {
@@ -245,7 +240,9 @@ module Samagotchi
245
240
  cancel_controller: nil,
246
241
  images: ImageInput.extract(@prompt)
247
242
  )
248
- if empty_answer?(result)
243
+ # No answer (the empty-answer retries used up): no text, or the chat
244
+ # loop's placeholder (LLM::ModelResult#empty_answer?).
245
+ if result.empty_answer?
249
246
  # Nothing to print: say so on stderr and fail, so a script that
250
247
  # pipes the answer doesn't take the silence for one.
251
248
  session.save
@@ -281,15 +278,6 @@ module Samagotchi
281
278
 
282
279
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
283
280
 
284
- # A -p --non-interactive turn that ended with no answer (the empty-answer
285
- # retries used up): no text, or the chat loop's placeholder.
286
- def empty_answer?(result)
287
- return false if result.respond_to?(:canceled?) && result.canceled?
288
- return false if result.respond_to?(:resumable?) && result.resumable?
289
-
290
- result.output.to_s.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?)
291
- end
292
-
293
281
  # Delete the scratch session, the last thing the REPL does. Quiet after
294
282
  # a one-shot or on the way out of an error or a signal.
295
283
  def delete_scratch(session, quiet:)
@@ -669,7 +657,7 @@ module Samagotchi
669
657
  # After a prompt or continue turn: TurnFlow keeps the checkpoint and the
670
658
  # continue offer; the REPL saves and tells the user.
671
659
  def finish_turn(session, result, continue:)
672
- if result.respond_to?(:canceled?) && result.canceled?
660
+ if result.canceled?
673
661
  outcome = @turn_flow.after_turn(result, continue: continue)
674
662
  # A cancelled continue is back where it started, the offer still open.
675
663
  return if outcome == :continue_cancelled
@@ -680,7 +668,7 @@ module Samagotchi
680
668
  # user's next message continues from it. !rollback restores the
681
669
  # pre-turn checkpoint for an explicit full discard (the renderer's
682
670
  # hint under :turn_canceled says so).
683
- save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
671
+ save_session(session) if result.conversation.is_a?(Array)
684
672
  return
685
673
  end
686
674
 
@@ -758,9 +746,9 @@ module Samagotchi
758
746
  ensure
759
747
  @surface.clear_slot(:hints)
760
748
  end
761
- canceled = result.respond_to?(:canceled?) && result.canceled?
749
+ canceled = result&.canceled?
762
750
  if result && !canceled
763
- session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
751
+ session.messages = result.conversation if result.conversation.is_a?(Array)
764
752
  @engine.store_model!(session)
765
753
  session.save
766
754
  end
@@ -996,15 +984,7 @@ module Samagotchi
996
984
  end
997
985
 
998
986
  def cancelled_result_from(messages, reason:)
999
- KernelLoop::Result.new(
1000
- output: "",
1001
- conversation: clone_messages(messages),
1002
- exhausted: false,
1003
- pending_tool_calls: false,
1004
- tool_activity: [],
1005
- canceled: true,
1006
- cancellation_reason: reason
1007
- )
987
+ LLM::ModelResult.new(text: "", conversation: clone_messages(messages), canceled: true, cancellation_reason: reason)
1008
988
  end
1009
989
 
1010
990
  # The status row under the prompt, from the Engine's state (what a
@@ -65,7 +65,7 @@ module Samagotchi
65
65
  key, value = ConfigFile.model_setting(names, :thinking, models: models)
66
66
  return [value, "models: #{key}"] if key
67
67
 
68
- host = target.entry.respond_to?(:thinking) ? target.entry.thinking : nil
68
+ host = target.entry.thinking
69
69
  return [host, "hosts.#{target.entry.name}"] if host
70
70
  return [global, "thinking.level"] if global
71
71
 
@@ -146,7 +146,7 @@ module Samagotchi
146
146
  # says the image is attached, that line replaces the text.
147
147
  # @return [Array(Hash, Array<Hash>)] the result and its refs
148
148
  def attach_images(call, result)
149
- vision = @kernel.vision if @kernel.respond_to?(:vision)
149
+ vision = @kernel.turn_settings.vision
150
150
  reason = if vision.nil? then "images can't be attached here"
151
151
  elsif !vision.sendable? then ImagePlan::CANT_SEE
152
152
  end
@@ -192,7 +192,7 @@ module Samagotchi
192
192
  end
193
193
 
194
194
  # The kernel's tools, for the activity line of a tool that isn't built in.
195
- def tools = @kernel.respond_to?(:tools) ? @kernel.tools : nil
195
+ def tools = @kernel.tools
196
196
 
197
197
  # A tool the registry has from a plugin (not core, not unknown).
198
198
  def plugin_tool?(name)
@@ -206,8 +206,7 @@ module Samagotchi
206
206
  # The Engine sets the kernel's gate (its context, later the approval
207
207
  # flow); a bare kernel (specs) gets one that only runs the hooks.
208
208
  def gate
209
- given = @kernel.guardrail_gate if @kernel.respond_to?(:guardrail_gate)
210
- given || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks if @kernel.respond_to?(:hooks) }, tools_lookup: -> { tools }))
209
+ @kernel.guardrail_gate || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks }, tools_lookup: -> { tools }))
211
210
  end
212
211
 
213
212
  # A gate that fails denies the call (fail closed).
@@ -254,8 +253,7 @@ module Samagotchi
254
253
 
255
254
  # A failing hook must not break the turn.
256
255
  def fire(name, event)
257
- hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
258
- hooks&.fire(name, event)
256
+ @kernel.hooks&.fire(name, event)
259
257
  rescue StandardError
260
258
  nil
261
259
  end
@@ -103,7 +103,7 @@ module Samagotchi
103
103
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
104
104
  def cancelled_proc(kctx)
105
105
  peers = kctx.peers
106
- -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
106
+ -> { !!peers&.cancelled? }
107
107
  end
108
108
 
109
109
  # @return [Registry] a new registry with the built-ins, in
@@ -43,7 +43,7 @@ module Samagotchi
43
43
  def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
44
44
  sd = peers.state_dir || Session.default_state_dir
45
45
  key = [peers.session_id, child_id]
46
- cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
46
+ cancelled = -> { peers.cancelled? }
47
47
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
48
48
  poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
49
49
  # The turn sent to is handed over: a later wait looks for a later one.
@@ -72,7 +72,7 @@ module Samagotchi
72
72
  # turn: its partial progress stays, !rollback can undo it) or
73
73
  # :continue_cancelled (back to before the continue; the offer stays)
74
74
  def after_turn(result, continue: false, no_interrupt: false)
75
- if result.respond_to?(:canceled?) && result.canceled?
75
+ if result.canceled?
76
76
  if continue
77
77
  restore(@continue_checkpoint)
78
78
  return :continue_cancelled
@@ -85,7 +85,7 @@ module Samagotchi
85
85
  return :cancelled
86
86
  end
87
87
 
88
- if result.respond_to?(:resumable?) && result.resumable?
88
+ if result.resumable?
89
89
  @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
90
90
  :continue_offered
91
91
  else
@@ -172,7 +172,7 @@ module Samagotchi
172
172
  end
173
173
 
174
174
  def conversation_of(result)
175
- result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
175
+ result.conversation if result.conversation.is_a?(Array)
176
176
  end
177
177
 
178
178
  def interrupted_turn_context(result)
@@ -198,7 +198,7 @@ module Samagotchi
198
198
  end
199
199
 
200
200
  def tool_trace(result)
201
- activities = result.respond_to?(:tool_activity) ? Array(result.tool_activity) : []
201
+ activities = Array(result.tool_activity)
202
202
  activities.last(SUMMARY_TOOLS_LIMIT).map do |activity|
203
203
  tool = activity[:tool].to_s.strip
204
204
  status = activity[:status].to_s.strip
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Samagotchi
6
+ # A session's turn as other threads see it: whether one runs, its cancel
7
+ # controller and event sink, the plugin steers waiting for its next
8
+ # boundary, and the activity clock the idle jobs read.
9
+ #
10
+ # The turn thread writes it (Engine#begin_turn, #release_turn); the idle
11
+ # scheduler, the bridge's request threads, plugin threads and the UIs
12
+ # read it. The lock is a leaf: nothing is called out while it is held,
13
+ # so any lock may be held around a call into it, and it is never held
14
+ # around anything else.
15
+ class TurnState
16
+ # @param clock [#call] monotonic seconds
17
+ def initialize(clock:)
18
+ @clock = clock
19
+ @lock = Monitor.new
20
+ @last_activity_at = clock.call
21
+ @activity_seq = 0
22
+ @running = false
23
+ @controller = nil
24
+ @sink = nil
25
+ @steers = []
26
+ end
27
+
28
+ # ── Activity clock ──────────────────────────────────────────────────
29
+
30
+ # Activity happened (user input, a finished turn): the clock moves to
31
+ # +now+ and the sequence advances, so the idle jobs re-arm.
32
+ # @param now [Float, nil] monotonic seconds; the clock's now by default
33
+ def record_activity(now = nil)
34
+ now = now ? now.to_f : @clock.call
35
+ @lock.synchronize do
36
+ @last_activity_at = now
37
+ @activity_seq += 1
38
+ end
39
+ end
40
+
41
+ # Start the idle window now without counting it as activity (the
42
+ # sequence stays): the Engine does once its plugins have loaded, so a
43
+ # slow plugin start doesn't shorten the first window.
44
+ def restart_clock!
45
+ now = @clock.call
46
+ @lock.synchronize { @last_activity_at = now }
47
+ end
48
+
49
+ # @return [Float] monotonic seconds of the last activity
50
+ def last_activity_at
51
+ @lock.synchronize { @last_activity_at }
52
+ end
53
+
54
+ # @return [Integer] how many times activity was recorded
55
+ def activity_seq
56
+ @lock.synchronize { @activity_seq }
57
+ end
58
+
59
+ # ── The turn ────────────────────────────────────────────────────────
60
+
61
+ # @return [Boolean] whether a turn runs
62
+ def running?
63
+ @lock.synchronize { @running }
64
+ end
65
+
66
+ # A turn begins: running, with its controller and sink, in one step.
67
+ def begin!(controller:, sink:)
68
+ @lock.synchronize do
69
+ @running = true
70
+ @controller = controller
71
+ @sink = sink
72
+ end
73
+ end
74
+
75
+ # The turn is over: not running, no controller, sink or steers.
76
+ # @return [Array<Hash>] the steers it never took ({text:, source:})
77
+ def finish!
78
+ @lock.synchronize do
79
+ @running = false
80
+ @controller = nil
81
+ @sink = nil
82
+ @steers.tap { @steers = [] }
83
+ end
84
+ end
85
+
86
+ # @return [CancellationController, nil] the running turn's controller
87
+ def controller
88
+ @lock.synchronize { @controller }
89
+ end
90
+
91
+ # Cancel the running turn, if any (outside the lock).
92
+ # @return [Boolean] whether a cancellation was triggered
93
+ def cancel!(reason)
94
+ ctrl = controller
95
+ return false unless ctrl
96
+
97
+ ctrl.cancel!(reason)
98
+ end
99
+
100
+ # @return [Array(Boolean, #call)] whether a turn runs, and its sink
101
+ def in_turn_sink
102
+ @lock.synchronize { [@running, @sink] }
103
+ end
104
+
105
+ # ── Steers ──────────────────────────────────────────────────────────
106
+
107
+ # Queue +text+ for the running turn's next boundary.
108
+ # @return [Boolean] whether it was queued (false: blank, or no turn)
109
+ def steer(text, source:)
110
+ text = text.to_s.strip
111
+ return false if text.empty?
112
+
113
+ @lock.synchronize do
114
+ return false unless @running
115
+
116
+ @steers << { text: text, source: source.to_s }
117
+ end
118
+ true
119
+ end
120
+
121
+ # @return [Array<Hash>] the steers queued so far ({text:, source:}), taken
122
+ def take_steers
123
+ @lock.synchronize { @steers.tap { @steers = [] } }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ require_relative "muted_memories"
6
+ require_relative "tools/memory"
7
+ require_relative "tools/read"
8
+
9
+ module Samagotchi
10
+ # The memories a session used: the ones its turns read (memory_read, or a
11
+ # read of a memories/*.md file) or preloaded. Deduped, in first-use
12
+ # order. Written by the turn thread (and the main thread setting the
13
+ # session), read by any (the bridge's snapshot, the UIs).
14
+ class UsedMemories
15
+ # The memory names a tool call reads, or nil when it reads none.
16
+ # @param call [Hash] {name:, content:}
17
+ # @return [Array<String>, String, nil]
18
+ def self.names_from_call(call)
19
+ return nil unless call.is_a?(Hash)
20
+
21
+ content = call[:content].to_s.strip
22
+ return nil if content.empty?
23
+
24
+ case call[:name].to_s
25
+ when Tools::MemoryRead::NAME
26
+ Tools::MemoryRead.parse_names(content).filter_map { |name| normalize(name) }
27
+ when Tools::Read::NAME
28
+ path = content.tr("\\", "/")
29
+ path.match?(%r{memories/.+\.md\z}) ? normalize(path) : nil
30
+ end
31
+ end
32
+
33
+ # A memory's name as the list keeps it: the basename without .md.
34
+ def self.normalize(raw)
35
+ value = raw.to_s.strip
36
+ return nil if value.empty?
37
+
38
+ base = File.basename(value, ".md").strip
39
+ base.empty? ? nil : base
40
+ end
41
+ private_class_method :normalize
42
+
43
+ # @param names [Array<String>] the names already used (a resumed session's)
44
+ def initialize(names = [])
45
+ @lock = Monitor.new
46
+ @names = []
47
+ add(names)
48
+ end
49
+
50
+ # @return [Array<String>] a copy of the list
51
+ def names
52
+ @lock.synchronize { @names.dup }
53
+ end
54
+
55
+ # Add names, skipping blanks and ones already there.
56
+ def add(names)
57
+ @lock.synchronize do
58
+ Array(names).each do |name|
59
+ value = name.to_s.strip
60
+ @names << value unless value.empty? || @names.include?(value)
61
+ end
62
+ end
63
+ self
64
+ end
65
+
66
+ # Add the names a session already used.
67
+ def absorb(session)
68
+ return self unless session
69
+
70
+ add(Array(session.used_memory_names))
71
+ end
72
+
73
+ # A memory read as it starts (:tool_call_started): its names join the
74
+ # list. A refused read of a muted memory is not a use of it.
75
+ # @param muted [Array<String>] the session's muted names (normalized)
76
+ # @return [Array<String>, nil] the names this call read, nil for any other event
77
+ def capture(event, muted:)
78
+ return nil unless event.is_a?(Hash) && event[:type] == :tool_call_started
79
+
80
+ call = event[:call].is_a?(Hash) ? event[:call] : {}
81
+ names = Array(self.class.names_from_call(call)).reject { |name| MutedMemories.muted?(name, muted) }
82
+ return nil if names.empty?
83
+
84
+ add(names)
85
+ names
86
+ end
87
+ end
88
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -60,10 +60,10 @@ module Samagotchi
60
60
 
61
61
  def self.native(target, entry, profile, trust_config:)
62
62
  client = target.client
63
- transport = client.respond_to?(:transport) ? client.transport.name : :llama_cpp
63
+ transport = client.transport.name
64
64
  return no("#{transport} hosts take no images (only llama.cpp does)") unless transport == :llama_cpp
65
65
 
66
- props = client.respond_to?(:server_props) ? client.server_props(model: target.bare_model) : nil
66
+ props = client.server_props(model: target.bare_model)
67
67
  return no("can't reach /props for the media marker") unless props&.answered?
68
68
 
69
69
  body = props.body.is_a?(Hash) ? props.body : {}
@@ -83,14 +83,14 @@ module Samagotchi
83
83
 
84
84
  def self.chat(target, entry, adapter)
85
85
  unless entry.remote?
86
- props = entry.client.respond_to?(:server_props) ? entry.client.server_props(model: target.bare_model) : nil
86
+ props = entry.client.server_props(model: target.bare_model)
87
87
  if props&.answered? && props.body.is_a?(Hash) && props.body.key?("modalities")
88
88
  vision = props.body.dig("modalities", "vision") == true
89
89
  return vision ? Answer.new(value: true, reason: nil) : no("the server has no vision model loaded (start llama.cpp with --mmproj)")
90
90
  end
91
91
  end
92
92
 
93
- listed = adapter.respond_to?(:image_input) ? adapter.image_input(model: target.bare_model) : nil
93
+ listed = adapter&.image_input(model: target.bare_model)
94
94
  return Answer.new(value: true, reason: nil) if listed == true
95
95
  return no("host #{entry.name} lists #{target.bare_model} as text-only") if listed == false
96
96
 
@@ -16,6 +16,7 @@ require_relative "../session_manager"
16
16
  require_relative "../session_commands"
17
17
  require_relative "../steer"
18
18
  require_relative "../host_registry"
19
+ require_relative "../model_catalog"
19
20
  require_relative "../model_profile"
20
21
  require_relative "../project_scope"
21
22
  require_relative "../prompt_history"
@@ -335,12 +336,10 @@ module Samagotchi
335
336
 
336
337
  root = dir && ProjectScope.root_for(dir)
337
338
  # Lazy retention sweep (once per 24h)
338
- if @manager.respond_to?(:retention_sweep_if_due)
339
- begin
340
- @manager.retention_sweep_if_due(state_dir: @state_dir)
341
- rescue StandardError
342
- nil
343
- end
339
+ begin
340
+ @manager.retention_sweep_if_due(state_dir: @state_dir)
341
+ rescue StandardError
342
+ nil
344
343
  end
345
344
  limit = sanitize_limit(req.params["limit"])
346
345
  offset = sanitize_offset(req.params["offset"])
@@ -417,19 +416,11 @@ module Samagotchi
417
416
  warnings << "the hosts are still listing their models"
418
417
  else
419
418
  default_host = registry.default_entry&.name
420
- ordered = results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }
421
- ordered.each do |host|
422
- data = results[host]
423
- if data[:error]
424
- warnings << "#{host}: #{data[:error]}"
425
- next
426
- end
427
- Array(data[:models]).each do |info|
428
- id = info.id.to_s
429
- next if id.strip.empty? || id.end_with?(":batch")
430
-
431
- models << { name: host == default_host ? id : "#{host}:#{id}", host: host, id: id }
432
- end
419
+ listing = ModelCatalog.listing(results, registry: registry)
420
+ warnings.concat(listing.warnings)
421
+ # the web's own spelling, kept as it was: bare on the default host
422
+ listing.rows.each do |row|
423
+ models << { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
433
424
  end
434
425
  end
435
426
  rescue StandardError => e
@@ -552,7 +543,7 @@ module Samagotchi
552
543
  # saved can't be answered (its turn is gone).
553
544
  pending = if turn_snapshot
554
545
  current_turn && current_turn["pending_question"]
555
- elsif owner && session.respond_to?(:pending_question)
546
+ elsif owner
556
547
  session.pending_question
557
548
  end
558
549
  # A /model in the worker changes it before the file catches up.
@@ -581,7 +572,7 @@ module Samagotchi
581
572
  session: session_json,
582
573
  history: read_history(id),
583
574
  messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
584
- cwd: session.respond_to?(:working_directory) ? session.working_directory : nil),
575
+ cwd: session.working_directory),
585
576
  failed_turn: failed_turn_for(session, raw_messages),
586
577
  current_turn: current_turn,
587
578
  queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
@@ -677,7 +668,7 @@ module Samagotchi
677
668
  line = body["line"].to_s.strip
678
669
  return error_response(400, "missing_fields", "line is required") if line.empty?
679
670
 
680
- @manager.resume_session(id, state_dir: @state_dir) if @manager.respond_to?(:resume_session)
671
+ @manager.resume_session(id, state_dir: @state_dir)
681
672
  # card: a card's action; its command events say so (no echo).
682
673
  request = ->(client) { client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true) }
683
674
  relay(id, live_bridge_client(id), request, what: "the command was not run", cant: "run commands") do |reply|
@@ -852,8 +843,6 @@ module Samagotchi
852
843
 
853
844
  # The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
854
845
  def session_owner(id)
855
- return nil unless @manager.respond_to?(:session_owner)
856
-
857
846
  @manager.session_owner(id, state_dir: @state_dir)
858
847
  rescue StandardError
859
848
  nil
@@ -1125,16 +1114,8 @@ module Samagotchi
1125
1114
  end
1126
1115
  end
1127
1116
 
1128
- # status is turn state (idle/running): SessionSummary.displayed_status,
1129
- # unless the manager can't tell who owns a session (then the file's
1130
- # word stands).
1117
+ # status is turn state (idle/running): SessionSummary.displayed_status.
1131
1118
  def displayed_status(session, snapshot = nil, owner: session_owner(session.id))
1132
- unless @manager.respond_to?(:session_owner)
1133
- return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
1134
-
1135
- return session.status
1136
- end
1137
-
1138
1119
  SessionSummary.displayed_status(session, snapshot, owner: owner)
1139
1120
  end
1140
1121
 
@@ -197,8 +197,6 @@ module Samagotchi
197
197
  end
198
198
 
199
199
  def sweep
200
- return unless @manager.respond_to?(:retention_sweep_if_due)
201
-
202
200
  @manager.retention_sweep_if_due(state_dir: @state_dir)
203
201
  rescue StandardError
204
202
  nil
@@ -288,8 +286,6 @@ module Samagotchi
288
286
  end
289
287
 
290
288
  def owner_of(id)
291
- return nil unless @manager.respond_to?(:session_owner)
292
-
293
289
  @manager.session_owner(id, state_dir: @state_dir)
294
290
  rescue StandardError
295
291
  nil
@@ -28,7 +28,7 @@ module Samagotchi
28
28
  # looks its project up)
29
29
  # @return [Hash] symbol keys
30
30
  def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
31
- used = session.respond_to?(:used_memory_names) ? Array(session.used_memory_names) : []
31
+ used = Array(session.used_memory_names)
32
32
  up = bridge_up?(session_dir, owner)
33
33
  {
34
34
  id: session.id,
@@ -43,10 +43,10 @@ module Samagotchi
43
43
  test_run: !!session.test_run,
44
44
  used_memory_names: used,
45
45
  # The session's --memory and --mute lists (info-bar tooltip).
46
- preloaded_memory_names: session.respond_to?(:preloaded_memory_names) ? Array(session.preloaded_memory_names) : [],
47
- muted_memory_names: session.respond_to?(:muted_memory_names) ? Array(session.muted_memory_names) : [],
46
+ preloaded_memory_names: Array(session.preloaded_memory_names),
47
+ muted_memory_names: Array(session.muted_memory_names),
48
48
  # The session that delegated this one (the `delegate` tool), else nil.
49
- parent_id: session.respond_to?(:parent_id) ? session.parent_id : nil,
49
+ parent_id: session.parent_id,
50
50
  first_preview: first_preview_for(session),
51
51
  owner: owner&.kind,
52
52
  # The saved recap's first sentence, for the session card.
@@ -64,14 +64,14 @@ module Samagotchi
64
64
  # ended. A question saved by a worker that died is not open.
65
65
  pending_question: owner ? pending_question_for(session) : nil,
66
66
  pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
67
- last_turn: session.respond_to?(:last_turn) ? session.last_turn : nil
67
+ last_turn: session.last_turn
68
68
  }
69
69
  end
70
70
 
71
71
  # The open question as {id:, kind:} ("approval" for a guardrail's,
72
72
  # else "question"); nil when there is none. The text stays out.
73
73
  def pending_question_for(session)
74
- pending = session.respond_to?(:pending_question) ? session.pending_question : nil
74
+ pending = session.pending_question
75
75
  return nil unless pending.is_a?(Hash) && pending[:id]
76
76
 
77
77
  { id: pending[:id], kind: pending[:kind].to_s == "approval" ? "approval" : "question" }
@@ -475,7 +475,7 @@ module Samagotchi
475
475
  refuse_queued_commands
476
476
  end
477
477
  yield result, error
478
- response = result.respond_to?(:output) ? result.output : nil
478
+ response = result&.output
479
479
  SessionInbox.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
480
480
  save_session
481
481
  end