samagotchi 0.11.0 → 0.13.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../session"
4
4
  require_relative "../reply_wait"
5
+ require_relative "../parent_report"
5
6
  require_relative "output_guardrails"
6
7
  require_relative "peers"
7
8
 
@@ -18,6 +19,10 @@ module Samagotchi
18
19
  module DelegateWait
19
20
  TIMEOUT_DEFAULT = 600
20
21
  POLL_INTERVAL = 0.5
22
+ # Seconds with no worker before the child counts as gone: as long as
23
+ # Delegate gives a starting child (its worker takes the owner lock
24
+ # only once it has booted).
25
+ OWNER_GRACE = 15
21
26
  # Cut like an execute result: full up to TRUNCATE_AT bytes, else a
22
27
  # head-and-tail preview of PREVIEW bytes.
23
28
  TRUNCATE_AT_BYTES = OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES
@@ -39,13 +44,16 @@ module Samagotchi
39
44
  # @param peers [Peers] the parent (session_id, state_dir, cancelled?)
40
45
  # @param timeout [Integer] seconds
41
46
  # @param poll_interval [Float]
47
+ # @param owner_grace [Numeric] seconds with no live worker before
48
+ # the child's worker counts as gone
42
49
  # @return [String] the tool result
43
- def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
50
+ def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL, owner_grace: OWNER_GRACE)
44
51
  sd = peers.state_dir || Session.default_state_dir
45
52
  key = [peers.session_id, child_id]
46
- cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
53
+ cancelled = -> { peers.cancelled? }
47
54
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
48
- poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
55
+ poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key],
56
+ owner_grace: owner_grace)
49
57
  # The turn sent to is handed over: a later wait looks for a later one.
50
58
  baselines.delete(key) if %i[done no_reply].include?(wait.status)
51
59
  case wait.status
@@ -62,6 +70,9 @@ module Samagotchi
62
70
  result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
63
71
  when :no_reply
64
72
  result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
73
+ when :worker_gone
74
+ result(child_id, "worker_gone",
75
+ "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
65
76
  else
66
77
  timeout_result(child_id, timeout)
67
78
  end
@@ -98,9 +109,10 @@ module Samagotchi
98
109
  "chi --attach #{child_id} shows it.")
99
110
  end
100
111
 
112
+ # The whole question, as `chi send --wait` prints it (ParentReport):
113
+ # its text, options and the commands that answer it.
101
114
  def waiting_text(child_id, pending)
102
- what = pending[:kind].to_s == "approval" ? "an approval" : "a question"
103
- "Child #{child_id} is waiting for an answer (#{what}); attach with chi --attach #{child_id} or answer it in the web. " \
115
+ "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}" \
104
116
  "delegate_result #{child_id} waits again once it is answered."
105
117
  end
106
118
 
@@ -25,7 +25,8 @@ module Samagotchi
25
25
 
26
26
  # @param cancelled [#call] true once the user stopped the turn: the
27
27
  # command is killed (or not started) and its output so far returned
28
- def self.call(command, cwd: nil, cancelled: -> { false })
28
+ # @param env [Hash, nil] added to the command's environment
29
+ def self.call(command, cwd: nil, cancelled: -> { false }, env: nil)
29
30
  return NOT_RUN_ON_STOP if cancelled.call
30
31
 
31
32
  command = command.strip
@@ -33,7 +34,8 @@ module Samagotchi
33
34
  return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
34
35
 
35
36
  timeout_sec = timeout_seconds
36
- stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled)
37
+ stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
38
+ env: env)
37
39
 
38
40
  stdout_block = output_block("stdout", stdout)
39
41
  stderr_block = output_block("stderr", stderr)
@@ -66,7 +68,7 @@ module Samagotchi
66
68
  end
67
69
  private_class_method :stopped_result
68
70
 
69
- def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false })
71
+ def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false }, env: nil)
70
72
  stdout_text = ""
71
73
  stderr_text = ""
72
74
  status = nil
@@ -75,7 +77,7 @@ module Samagotchi
75
77
  started = monotonic_time
76
78
  deadline = started + timeout_sec
77
79
 
78
- Open3.popen3(command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
80
+ Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
79
81
  stdin.close
80
82
  stdout_reader = reader_thread_for(stdout)
81
83
  stderr_reader = reader_thread_for(stderr)
@@ -173,11 +175,9 @@ module Samagotchi
173
175
  end
174
176
  private_class_method :monotonic_time
175
177
 
178
+ # execute.timeout_sec (SAMAGOTCHI_EXECUTE_TIMEOUT_SEC); 0 or less = the default.
176
179
  def self.timeout_seconds
177
- value = Integer(ENV["SAMAGOTCHI_EXECUTE_TIMEOUT_SEC"].to_s, exception: false)
178
- return TIMEOUT_SEC if value.nil? || value <= 0
179
-
180
- value
180
+ OutputGuardrails.config_positive_int("execute.timeout_sec", TIMEOUT_SEC)
181
181
  end
182
182
  private_class_method :timeout_seconds
183
183
 
@@ -9,8 +9,9 @@ module Samagotchi
9
9
 
10
10
  def self.name = NAME
11
11
 
12
- def self.call(command, cwd: nil, env: nil)
13
- record, error = TaskRuntime.create_task(command, cwd: cwd, env: env)
12
+ # @param marker_env [Hash] set after the model's env (Builtins.parent_env)
13
+ def self.call(command, cwd: nil, env: nil, marker_env: {})
14
+ record, error = TaskRuntime.create_task(command, cwd: cwd, env: env, marker_env: marker_env)
14
15
  return error if error
15
16
 
16
17
  [
@@ -19,10 +19,13 @@ module Samagotchi
19
19
  METADATA_VERSION = 1
20
20
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
21
21
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
22
+ # Set by chi (Builtins.parent_env); the model's env can't change it.
23
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
22
24
 
23
25
  module_function
24
26
 
25
- def create_task(command, cwd: nil, env: nil)
27
+ # @param marker_env [Hash] chi's own variables, set over the model's +env+
28
+ def create_task(command, cwd: nil, env: nil, marker_env: {})
26
29
  normalized_command = command.to_s.strip
27
30
  return [nil, "Error: command is required"] if normalized_command.empty?
28
31
 
@@ -32,6 +35,8 @@ module Samagotchi
32
35
  spawn_env, env_error = spawn_env_for(env)
33
36
  return [nil, env_error] if env_error
34
37
 
38
+ spawn_env = spawn_env.merge(marker_env || {})
39
+
35
40
  task_id = generate_task_id
36
41
  task_dir = task_dir_for(task_id)
37
42
  output_path = File.join(task_dir, OUTPUT_FILENAME)
@@ -258,7 +263,7 @@ module Samagotchi
258
263
  overrides.each do |key, value|
259
264
  return [nil, "Error: env keys and values must be strings"] unless key.is_a?(String) && value.is_a?(String)
260
265
  return [nil, "Error: env keys and values cannot contain NUL bytes"] if key.include?("\0") || value.include?("\0")
261
- return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key)
266
+ return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key) || MARKER_ENV_KEYS.include?(key)
262
267
 
263
268
  normalized[key] = value
264
269
  end
@@ -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.13.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
@@ -548,11 +539,12 @@ module Samagotchi
548
539
  last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
549
540
  current_turn = turn_snapshot && turn_snapshot["current_turn"]
550
541
  owner = session_owner(id)
551
- # The file's question only with a live owner: one a dead worker
552
- # saved can't be answered (its turn is gone).
542
+ # The file's question only while a worker owns the session: one a
543
+ # dead worker saved can't be answered (its turn is gone), nor a chi
544
+ # REPL's (it shares nothing with the web).
553
545
  pending = if turn_snapshot
554
546
  current_turn && current_turn["pending_question"]
555
- elsif owner && session.respond_to?(:pending_question)
547
+ elsif owner&.worker?
556
548
  session.pending_question
557
549
  end
558
550
  # A /model in the worker changes it before the file catches up.
@@ -581,7 +573,7 @@ module Samagotchi
581
573
  session: session_json,
582
574
  history: read_history(id),
583
575
  messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
584
- cwd: session.respond_to?(:working_directory) ? session.working_directory : nil),
576
+ cwd: session.working_directory),
585
577
  failed_turn: failed_turn_for(session, raw_messages),
586
578
  current_turn: current_turn,
587
579
  queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
@@ -677,7 +669,7 @@ module Samagotchi
677
669
  line = body["line"].to_s.strip
678
670
  return error_response(400, "missing_fields", "line is required") if line.empty?
679
671
 
680
- @manager.resume_session(id, state_dir: @state_dir) if @manager.respond_to?(:resume_session)
672
+ @manager.resume_session(id, state_dir: @state_dir)
681
673
  # card: a card's action; its command events say so (no echo).
682
674
  request = ->(client) { client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true) }
683
675
  relay(id, live_bridge_client(id), request, what: "the command was not run", cant: "run commands") do |reply|
@@ -852,8 +844,6 @@ module Samagotchi
852
844
 
853
845
  # The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
854
846
  def session_owner(id)
855
- return nil unless @manager.respond_to?(:session_owner)
856
-
857
847
  @manager.session_owner(id, state_dir: @state_dir)
858
848
  rescue StandardError
859
849
  nil
@@ -1125,16 +1115,8 @@ module Samagotchi
1125
1115
  end
1126
1116
  end
1127
1117
 
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).
1118
+ # status is turn state (idle/running): SessionSummary.displayed_status.
1131
1119
  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
1120
  SessionSummary.displayed_status(session, snapshot, owner: owner)
1139
1121
  end
1140
1122
 
@@ -46,7 +46,8 @@ module Samagotchi
46
46
 
47
47
  QWEN_THINK = %r{<think>(.*?)</think>}m
48
48
  QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
49
- GEMMA_THOUGHT = /<\|channel>thought(.*?)(?:<channel\|>|\z)/m
49
+ GEMMA_THOUGHT = Regexp.new("#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN)}(.*?)" \
50
+ "(?:#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}|\\z)", Regexp::MULTILINE)
50
51
 
51
52
  CallRef = Struct.new(:name, :arguments)
52
53
 
@@ -16,6 +16,7 @@ require_relative "../config"
16
16
  require_relative "../project_scope"
17
17
  require_relative "../log"
18
18
  require_relative "../version"
19
+ require_relative "../cli/exit"
19
20
 
20
21
  module Samagotchi
21
22
  module Web
@@ -99,7 +100,7 @@ module Samagotchi
99
100
  # Ctrl-C: the server has stopped (start's ensure); one line, and the
100
101
  # status a shell gives a command it interrupted.
101
102
  puts "#{"\n" if $stdout.tty?}Chi Web stopped."
102
- 130
103
+ CLI::Exit::INTERRUPTED
103
104
  end
104
105
 
105
106
  # @param hub [SessionHub, nil] the session projection the page streams
@@ -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.
@@ -61,22 +61,14 @@ module Samagotchi
61
61
  bridge_up: up,
62
62
  # For the tab's notifications (notify.js): an open question, the
63
63
  # running turn's open card with actions, and how the last turn
64
- # ended. A question saved by a worker that died is not open.
65
- pending_question: owner ? pending_question_for(session) : nil,
64
+ # ended. A question saved by a worker that died is not open, nor a
65
+ # chi REPL's (SessionManager.worker_live?'s rule: only a worker shares it).
66
+ pending_question: session.waiting_question(live: !!owner&.worker?),
66
67
  pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
67
- last_turn: session.respond_to?(:last_turn) ? session.last_turn : nil
68
+ last_turn: session.last_turn
68
69
  }
69
70
  end
70
71
 
71
- # The open question as {id:, kind:} ("approval" for a guardrail's,
72
- # else "question"); nil when there is none. The text stays out.
73
- def pending_question_for(session)
74
- pending = session.respond_to?(:pending_question) ? session.pending_question : nil
75
- return nil unless pending.is_a?(Hash) && pending[:id]
76
-
77
- { id: pending[:id], kind: pending[:kind].to_s == "approval" ? "approval" : "question" }
78
- end
79
-
80
72
  # status is turn state (idle/running). The live worker's snapshot is the
81
73
  # truth; on disk, a "running" with no live owner was left by a worker
82
74
  # that died mid-turn.
@@ -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