samagotchi 0.3.0 → 0.4.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. metadata +12 -1
@@ -20,12 +20,14 @@ module Samagotchi
20
20
  # @param label [String, nil] a plugin tool's label ("chrome:
21
21
  # screenshot"): the question names the tool by it, as its row does;
22
22
  # approval[:tool] stays the raw name
23
+ # @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
24
+ # write: the card shows its diff, the question text one change line
23
25
  # @return [Hash] open_question fields
24
- def payload(verdict, label: nil)
26
+ def payload(verdict, label: nil, preview: nil)
25
27
  scopes = offered_scopes(verdict)
26
28
  targets = verdict.targets
27
29
  {
28
- question: question_text(verdict, label: label),
30
+ question: question_text(verdict, label: label, preview: preview),
29
31
  options: scopes.map { |scope| label(scope, verdict) } + [DENY],
30
32
  header: HEADER,
31
33
  multi_select: false,
@@ -44,7 +46,8 @@ module Samagotchi
44
46
  rule: verdict.rule,
45
47
  source: verdict.source,
46
48
  reason: verdict.reason,
47
- scopes: scopes
49
+ scopes: scopes,
50
+ preview: preview
48
51
  }.compact
49
52
  }
50
53
  end
@@ -112,7 +115,8 @@ module Samagotchi
112
115
  # execute: git push origin main
113
116
  # in /path/to/repo (repo samagotchi, branch main)
114
117
  # why: git push publishes commits (rule git-push, bundle guardrails)
115
- def question_text(verdict, label: nil)
118
+ # change: +3 −1 (edit/write, from the preview)
119
+ def question_text(verdict, label: nil, preview: nil)
116
120
  targets = verdict.targets
117
121
  tool = label || targets&.tool || verdict.call[:name].to_s
118
122
  what = targets&.command || (targets && targets.paths.join(", "))
@@ -127,8 +131,26 @@ module Samagotchi
127
131
  who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
128
132
  reason = verdict.reason.to_s.strip
129
133
  lines << " why: #{reason.empty? ? "(no reason given)" : reason} (#{who})"
134
+ change = change_text(preview)
135
+ lines << " change: #{change}" if change
130
136
  lines.join("\n")
131
137
  end
138
+
139
+ # One line for an EditPreview: "+3 −1", "new file, 12 lines",
140
+ # "would fail: …" or "not shown (binary file)". The diff itself stays
141
+ # out of the question text (size); the card and the TUI show it.
142
+ def change_text(preview)
143
+ return nil unless preview.is_a?(Hash)
144
+
145
+ get = ->(key) { preview[key] || preview[key.to_s] }
146
+ return "would fail: #{get.(:error)}" if get.(:error)
147
+ return "not shown (#{get.(:skipped)})" if get.(:skipped)
148
+
149
+ added = get.(:added).to_i
150
+ return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.(:new_file)
151
+
152
+ "+#{added} −#{get.(:removed).to_i}"
153
+ end
132
154
  end
133
155
  end
134
156
  end
@@ -7,7 +7,7 @@ module Samagotchi
7
7
  # guardrail that silently vanished is the failure this guards against);
8
8
  # the rest are only announced.
9
9
  class LoadFailures
10
- Failure = Struct.new(:what, :reason, :required, keyword_init: true)
10
+ Failure = Struct.new(:what, :reason, :required, :group, keyword_init: true)
11
11
 
12
12
  def initialize
13
13
  @list = []
@@ -15,8 +15,14 @@ module Samagotchi
15
15
  end
16
16
 
17
17
  # @param what [String] e.g. "hook guard.rb (config)"
18
- def add(what, reason, required:)
19
- @mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required) }
18
+ # @param group [Symbol, nil] what loads it again (#drop): :rules
19
+ def add(what, reason, required:, group: nil)
20
+ @mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required, group: group) }
21
+ end
22
+
23
+ # Forget a group's failures before it loads again.
24
+ def drop(group)
25
+ @mutex.synchronize { @list.reject! { |failure| failure.group == group } }
20
26
  end
21
27
 
22
28
  def list = @mutex.synchronize { @list.dup }
@@ -29,9 +29,10 @@ module Samagotchi
29
29
  # profile: the configured prompt profile name, if any;
30
30
  # first_token_timeout: the configured first-token limit (see #first_token_limit);
31
31
  # vision: the configured true/false (VisionSupport), nil when unset;
32
- # sampling: the configured request parameters (SamplingSettings), nil when unset.
32
+ # sampling: the configured request parameters (SamplingSettings), nil when unset;
33
+ # thinking: the configured level (Thinking), nil when unset.
33
34
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
34
- :first_token_timeout, :vision, :sampling, keyword_init: true) do
35
+ :first_token_timeout, :vision, :sampling, :thinking, keyword_init: true) do
35
36
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
36
37
  # raw-prompt loop.
37
38
  def chat? = api == :openai
@@ -87,9 +88,10 @@ module Samagotchi
87
88
  entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
88
89
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
89
90
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
90
- vision: cfg[:vision], sampling: cfg[:sampling])
91
+ vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking])
91
92
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
92
- first_token_timeout: entry.first_token_limit, name: entry.name)
93
+ first_token_timeout: entry.first_token_limit, name: entry.name,
94
+ api_key_env: entry.api_key_env, env: env)
93
95
  @entries[entry.name] = entry
94
96
  end
95
97
  # Fallback single entry (should already be synthesized by hosts_config, but guard)
@@ -117,10 +119,6 @@ module Samagotchi
117
119
  nil
118
120
  end
119
121
 
120
- def entry_names
121
- @entries.keys
122
- end
123
-
124
122
  def default_entry
125
123
  @entries["default"] || @entries.values.first
126
124
  end
@@ -149,10 +147,8 @@ module Samagotchi
149
147
  rescue StandardError
150
148
  nil
151
149
  end
152
- entry = find_entry(host_ref)
153
- return [entry, bare] if entry
154
- # Unknown prefix — treat as bare model on default host
155
- return [default_entry, raw_model.to_s.strip]
150
+ # parse_qualified_model names a host only when it is one of ours
151
+ return [find_entry(host_ref), bare]
156
152
  end
157
153
  # Unqualified: also try alias resolution for discovery (small -> gemma-small or small -> small-box:gemma-small)
158
154
  begin
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "llm/openai_chat"
5
+ require_relative "thinking"
6
+ require_relative "log"
5
7
 
6
8
  module Samagotchi
7
9
  # Standalone summarizer for the idle session-recap feature.
@@ -33,17 +35,6 @@ module Samagotchi
33
35
  # Up to 10 sentences (recap.sentences) need ~350 tokens with thinking off.
34
36
  MAX_TOKENS = 512
35
37
 
36
- # Request fields that turn thinking off. A reasoning model otherwise
37
- # spends the budget thinking and the recap stops mid-sentence.
38
- # - chat_template_kwargs.enable_thinking: the chat template's switch
39
- # (llama.cpp with Qwen/Gemma templates); templates without it ignore it.
40
- # - reasoning_effort "none": the OpenAI-style knob, for servers that
41
- # ignore the template switch (Splash thought until max_tokens).
42
- THINKING_OFF = {
43
- chat_template_kwargs: { enable_thinking: false },
44
- reasoning_effort: "none"
45
- }.freeze
46
-
47
38
  # @param base_url [String] the OpenAI API base, e.g. http://host:8081/v1
48
39
  # @param api_key_env [String, nil] the variable holding the host's key
49
40
  # @param timeout [Numeric] HTTP request timeout. Kept to the recap's own
@@ -119,10 +110,17 @@ module Samagotchi
119
110
  # +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
120
111
  # or all of it, with "…".
121
112
  def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true)
122
- response = @chat.chat(
123
- messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
124
- options: { max_tokens: max_tokens, **THINKING_OFF }
125
- )
113
+ response = begin
114
+ request(messages, max_tokens, cancel_controller)
115
+ rescue LLM::BadRequest => e
116
+ raise unless e.reasoning_refused? && !@thinking_refused
117
+
118
+ # The host won't turn thinking off (gpt-oss): ask again without the
119
+ # fields, and leave them out from now on.
120
+ @thinking_refused = true
121
+ Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
122
+ request(messages, max_tokens, cancel_controller)
123
+ end
126
124
  content = response.text
127
125
  reasoning = response.reasoning
128
126
  raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
@@ -143,5 +141,16 @@ module Samagotchi
143
141
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
144
142
  end
145
143
 
144
+ def request(messages, max_tokens, cancel_controller)
145
+ @chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
146
+ options: { max_tokens: max_tokens, **thinking_fields })
147
+ end
148
+
149
+ # A recap or side answer is short and tool-less: thinking off, whatever
150
+ # the model's level (a reasoning model otherwise spends the budget
151
+ # thinking and the recap stops mid-sentence). None once the host refused them.
152
+ def thinking_fields
153
+ @thinking_refused ? {} : Thinking.chat_fields(:off)
154
+ end
146
155
  end
147
156
  end
@@ -9,7 +9,7 @@ module Samagotchi
9
9
  # (one background thread for the whole idle layer).
10
10
  #
11
11
  # Two delivery paths cooperate:
12
- # 1. Pull-based: Engine#maybe_inject_reminders / collect_due_reminders
12
+ # 1. Pull-based: Engine#collect_due_reminders
13
13
  # reads ReminderStore#due_reminders at turn start → injects
14
14
  # [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
15
15
  # 2. Synthetic turns: when this job's tick finds due reminders while
@@ -43,7 +43,7 @@ module Samagotchi
43
43
  @mutex.synchronize { @due_reminder_name }
44
44
  end
45
45
 
46
- # Get all due reminders (from ReminderStore). Called by Engine#maybe_inject_reminders.
46
+ # Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
47
47
  # Thread-safe. Returns all due reminders, not just one.
48
48
  # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
49
49
  def due_reminders
@@ -262,12 +262,7 @@ module Samagotchi
262
262
  next message unless images.is_a?(Array) && !images.empty?
263
263
 
264
264
  kept, gone = from ? images.partition { |ref| valid_ref?(from, ref) } : [[], images]
265
- kept.each do |ref|
266
- file = (ref[:file] || ref["file"]).to_s
267
- target = File.join(to.to_s, file)
268
- FileUtils.mkdir_p(File.dirname(target))
269
- FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
270
- end
265
+ kept.each { |ref| copy_file(ref, from: from, to: to) }
271
266
  dropped += gone.size
272
267
  message = message.reject { |key, _| key.to_s == "images" }
273
268
  message[:images] = kept unless kept.empty?
@@ -280,6 +275,15 @@ module Samagotchi
280
275
  [copied, dropped]
281
276
  end
282
277
 
278
+ # One stored image's file from session dir +from+ into +to+ (kept when
279
+ # it is there already: the name is the content's hash).
280
+ def self.copy_file(ref, from:, to:)
281
+ file = (ref[:file] || ref["file"]).to_s
282
+ target = File.join(to.to_s, file)
283
+ FileUtils.mkdir_p(File.dirname(target))
284
+ FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
285
+ end
286
+
283
287
  # A ref for an image already stored in this session (a web upload),
284
288
  # rebuilt from the file itself: only its name comes from the caller.
285
289
  def self.ref_for(session_dir, file:, name: nil, source: "user")
@@ -11,6 +11,7 @@ require_relative "client"
11
11
  require_relative "llm/errors"
12
12
  require_relative "log"
13
13
  require_relative "empty_answer_retry"
14
+ require_relative "thinking"
14
15
  require_relative "hooks"
15
16
  require_relative "pending_input_queue"
16
17
  require_relative "steer"
@@ -179,6 +180,9 @@ module Samagotchi
179
180
  # The turn's request parameters (SamplingSettings.for), set by the Engine
180
181
  # per turn; empty or nil sends none.
181
182
  attr_accessor :sampling
183
+ # The turn's thinking level (Thinking.resolve), set by the Engine per
184
+ # turn; its own accessor, since the native path sends @sampling as is.
185
+ attr_accessor :thinking
182
186
  # Tools::Peers (or the Engine's live view of it): the session
183
187
  # list_sessions and send_note speak for; nil outside a session.
184
188
  attr_accessor :peers
@@ -217,13 +221,17 @@ module Samagotchi
217
221
  @retry_generation = false
218
222
  context_status = nil
219
223
  stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
224
+ # Qwen with thinking off: an empty thought after the cue, so the model
225
+ # answers at once. Kept in the turn's model messages, so each tool-loop
226
+ # prompt starts with what the server already has cached.
227
+ prefill = Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill
220
228
  partial_assistant_buffer = +""
221
229
 
222
230
  effective_max_iterations = @no_interrupt ? 1000 : max_iterations
223
231
  effective_max_tool_output_chars = resolve_output_char_cap(max_tool_output_chars)
224
232
  effective_max_iterations.times do |iteration_index|
225
233
  inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
226
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
234
+ prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
227
235
  image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
228
236
  context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
229
237
  context_status = emit_context_status_event(on_stream_event, prompt, iteration_index: iteration_index, state: context_state, window: context_window,
@@ -232,7 +240,7 @@ module Samagotchi
232
240
  # The model's own copy, on the tail (the prompt cache keeps its
233
241
  # prefix), then the prompt again with it.
234
242
  conversation << line
235
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
243
+ prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
236
244
  end
237
245
  emit_stream_event(
238
246
  on_stream_event,
@@ -310,7 +318,7 @@ module Samagotchi
310
318
  after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
311
319
  messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
312
320
  fire_hook(:after_generation, after_gen_event) if @hooks
313
- conversation << { role: "model", content: response }
321
+ conversation << { role: "model", content: prefill + response.to_s }
314
322
 
315
323
  # Profile-specific parse (incl. Qwen unterminated-block recovery); the
316
324
  # returned fragment (non-nil only for Qwen) is fed back on the next
@@ -365,6 +373,7 @@ module Samagotchi
365
373
  image_counts = []
366
374
  shown_params = []
367
375
  shown_labels = []
376
+ diffs = []
368
377
  results = calls.map.with_index do |call, call_index|
369
378
  run = tool_runner.run(call, iteration: iteration_index + 1, call_index: call_index + 1,
370
379
  call_count: calls.length, on_stream_event: on_stream_event,
@@ -374,6 +383,7 @@ module Samagotchi
374
383
  image_counts << Array(run[:images]).size
375
384
  shown_params << run[:shown_params]
376
385
  shown_labels << run[:shown_label]
386
+ diffs << run[:diff]
377
387
  run[:output]
378
388
  end.join("\n\n---\n\n")
379
389
  emit_stream_event(on_stream_event, type: :tool_dispatch_completed, iteration: iteration_index + 1, call_count: calls.length)
@@ -387,6 +397,9 @@ module Samagotchi
387
397
  # a built-in), for the web's reload; the prompt never reads it.
388
398
  tool_response[:tool_params] = shown_params if shown_params.any?
389
399
  tool_response[:tool_labels] = shown_labels if shown_labels.any?
400
+ # What each edit/write changed (nil for other calls), in call order,
401
+ # for the web's reload; the prompt never reads it.
402
+ tool_response[:tool_diffs] = diffs if diffs.any?
390
403
  conversation << tool_response
391
404
  pending_tool_calls = true
392
405
  rescue Client::RequestCancelled => e
@@ -828,14 +841,6 @@ module Samagotchi
828
841
  end
829
842
  end
830
843
 
831
- # Parse tool calls from raw model output using the active profile's
832
- # ToolCallParser strategy (Gemma 4 or Qwen 3.6).
833
- # Thought content is intentionally left intact while a tool-call turn is in
834
- # progress to preserve same-turn reasoning context between tool calls.
835
- def parse_tool_calls(text)
836
- parser.parse(text.to_s)
837
- end
838
-
839
844
  public
840
845
 
841
846
  # Public wrapper so other loops (e.g. the chat loop) can strip
@@ -974,76 +979,18 @@ module Samagotchi
974
979
  }
975
980
  end
976
981
 
982
+ # ask_user_question: validate the call, then hand the payload to the
983
+ # Engine's question flow (question_handler, which blocks until the user
984
+ # answers). Without one (headless), the payload as JSON so the model sees
985
+ # the options and can ask in plain text.
977
986
  def handle_ask_user_question(call)
978
- question = (call[:question] || call[:content]).to_s.strip
979
- raw_opts = call[:options]
980
- # Dumb-model tolerant: raw may be String JSON, Array, or malformed with brackets/quotes
981
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
982
- # Fallback for case where raw was String like '["a","b"]' but lenient returned [] due to edge parse, try raw string of params
983
- if options.empty? && raw_opts.is_a?(String)
984
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts.to_s)
985
- end
986
- header = call[:header].to_s.strip
987
- header = nil if header.empty?
988
- multi = call[:multi_select]
989
- free = call[:allow_freeform]
990
- # Normalize booleans from string forms (Gemma passes "true"/"false" as strings)
991
- multi = normalize_ask_bool(multi)
992
- free = normalize_ask_bool(free)
993
-
994
- if question.empty?
995
- return "Error: ask_user_question requires 'question'"
996
- end
997
- # Dumb-model tolerant: salvage single-option parse glitches, but still require at least 1
998
- if options.size < 1
999
- alt = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(call[:content].to_s) if call[:content]
1000
- options = alt unless alt.empty?
1001
- end
1002
- if options.empty?
1003
- return "Error: ask_user_question requires 2-8 options (got 0). Provide e.g. options=[\"Cats\",\"Dogs\"]"
1004
- end
1005
- if options.size == 1
1006
- # Allow single-option salvage for dumb models (will still render, user can answer or provide freeform)
1007
- elsif options.size < 2 || options.size > 8
1008
- return "Error: ask_user_question requires 2-8 options (got #{options.size}). Provide e.g. options=[\"Cats\",\"Dogs\"]"
1009
- end
1010
-
1011
- # If an Engine-level blocking handler is registered (TUI/Web), delegate
1012
- # there (Engine sets question_handler). Otherwise fall back to a
1013
- # non-blocking JSON preview so the model can still see a structured response.
1014
- handler = @question_handler
1015
-
1016
- payload = {
1017
- question: question,
1018
- options: options,
1019
- header: header,
1020
- multi_select: !!multi,
1021
- allow_freeform: !!free
1022
- }.compact
1023
-
1024
- if handler
1025
- begin
1026
- result = handler.call(payload)
1027
- return result.to_s
1028
- rescue => e
1029
- return "Error: ask_user_question handler failed: #{e.message}"
1030
- end
1031
- end
1032
-
1033
- # Headless fallback: return JSON so model sees structured options and can
1034
- # fallback to plain text qualification.
1035
- JSON.pretty_generate(payload)
1036
- end
987
+ payload = Samagotchi::Tools::AskUserQuestion.validate(call)
988
+ return payload if payload.is_a?(String)
989
+ return JSON.pretty_generate(payload) unless @question_handler
1037
990
 
1038
- def normalize_ask_bool(v)
1039
- return nil if v.nil?
1040
- return v if v == true || v == false
1041
-
1042
- s = v.to_s.strip.downcase
1043
- return true if %w[1 true yes on].include?(s)
1044
- return false if %w[0 false no off].include?(s)
1045
-
1046
- nil
991
+ @question_handler.call(payload).to_s
992
+ rescue => e
993
+ "Error: ask_user_question handler failed: #{e.message}"
1047
994
  end
1048
995
  end
1049
996
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "socket"
6
+ require_relative "session"
7
+ require_relative "version"
8
+
9
+ module Samagotchi
10
+ # Which chi versions the running processes run, for `chi update`: session
11
+ # workers (their bridge.json sidecar names the version since chi update exists; an
12
+ # older one names none) and a `chi web` on its port (/api/info). Read-only:
13
+ # a dead worker's sidecar is left for the next client to clean up.
14
+ module LiveVersions
15
+ PROBE_TIMEOUT = 0.2
16
+ WEB_TIMEOUT = 0.5
17
+
18
+ # version is nil for a sidecar written before sidecars carried one.
19
+ Worker = Struct.new(:session_id, :version, keyword_init: true)
20
+
21
+ module_function
22
+
23
+ # @return [Array<Worker>] the workers whose Bridge answers, by session id
24
+ def workers(state_dir: Session.default_state_dir)
25
+ Dir[File.join(state_dir, "*", "bridge.json")].sort.filter_map do |sidecar|
26
+ data = JSON.parse(File.read(sidecar))
27
+ next unless data.is_a?(Hash) && listening?(data["port"].to_i)
28
+
29
+ Worker.new(session_id: File.basename(File.dirname(sidecar)), version: data["version"])
30
+ rescue JSON::ParserError, SystemCallError
31
+ nil
32
+ end
33
+ end
34
+
35
+ # The live workers on another version than +version+ (unknown counts).
36
+ def stale_workers(version = VERSION, state_dir: Session.default_state_dir)
37
+ workers(state_dir: state_dir).reject { |w| w.version == version }
38
+ end
39
+
40
+ # The version a chi web on host:port runs, or nil when nothing (or not
41
+ # chi web) answers.
42
+ def web_version(host, port, timeout: WEB_TIMEOUT)
43
+ response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
44
+ info = response.code.to_i == 200 ? JSON.parse(response.body.to_s) : nil
45
+ info.is_a?(Hash) && info["app"] == "chi-web" ? info["version"].to_s : nil
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ def listening?(port, host: "127.0.0.1")
51
+ return false unless port.positive?
52
+
53
+ Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
54
+ true
55
+ rescue StandardError
56
+ false
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Samagotchi
6
+ module LLM
7
+ # A host's API key: the environment variable its api_key_env: names,
8
+ # sent as `Authorization: Bearer <key>` on every request LLM::HTTP makes
9
+ # for the host (llama.cpp started with --api-key, or a provider). The
10
+ # key itself never goes into a message.
11
+ ApiKey = Data.define(:env_name, :host, :env) do
12
+ # nil for a host without api_key_env: its requests carry no header.
13
+ def self.for(env_name, host:, env: ENV)
14
+ name = env_name.to_s.strip
15
+ name.empty? ? nil : new(env_name: name, host: host.to_s, env: env)
16
+ end
17
+
18
+ # What to try after a 401/403 from a host that has no api_key_env.
19
+ def self.missing_hint(host)
20
+ "the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
21
+ end
22
+
23
+ # Sets the header, or raises AuthError when the variable is not set.
24
+ def authorize(request)
25
+ key = env[env_name].to_s
26
+ raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
27
+
28
+ request["Authorization"] = "Bearer #{key}"
29
+ end
30
+
31
+ # What to try after a 401/403 with the key sent.
32
+ def hint = "check #{env_name} (the API key for host #{host})"
33
+
34
+ # Without the environment: it holds this key and every other secret.
35
+ def inspect = "#<#{self.class.name} #{env_name} host=#{host}>"
36
+ alias_method :to_s, :inspect
37
+
38
+ def pretty_print(printer) = printer.text(inspect)
39
+ end
40
+ end
41
+ end