samagotchi 0.28.0 → 0.30.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 +84 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/hooks.md +1 -1
  5. data/docs/plugins.md +1 -1
  6. data/docs/sessions.md +3 -3
  7. data/lib/samagotchi/bundle_command.rb +4 -2
  8. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  10. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  11. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/command_steps.rb +35 -15
  14. data/lib/samagotchi/engine.rb +4 -3
  15. data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
  16. data/lib/samagotchi/host_registry.rb +4 -0
  17. data/lib/samagotchi/kernel_loop.rb +27 -7
  18. data/lib/samagotchi/llm/chat_loop.rb +12 -6
  19. data/lib/samagotchi/memory_bundle/installer.rb +14 -0
  20. data/lib/samagotchi/model_list_store.rb +117 -0
  21. data/lib/samagotchi/model_profile.rb +43 -0
  22. data/lib/samagotchi/send_command.rb +5 -3
  23. data/lib/samagotchi/session.rb +5 -0
  24. data/lib/samagotchi/session_commands.rb +6 -2
  25. data/lib/samagotchi/session_manager.rb +9 -2
  26. data/lib/samagotchi/session_metrics.rb +6 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
  28. data/lib/samagotchi/terminal_ui.rb +35 -5
  29. data/lib/samagotchi/tool_activity.rb +38 -8
  30. data/lib/samagotchi/tools/builtins.rb +10 -7
  31. data/lib/samagotchi/tools/delegate.rb +14 -10
  32. data/lib/samagotchi/tools/execute.rb +3 -1
  33. data/lib/samagotchi/tools/task_runtime.rb +21 -0
  34. data/lib/samagotchi/turn_note.rb +10 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/public/activity.js +2 -1
  37. data/lib/samagotchi/web/public/app.js +23 -11
  38. data/lib/samagotchi/web/public/command_view.js +19 -5
  39. data/lib/samagotchi/web/public/index.html +51 -25
  40. data/lib/samagotchi/web/public/sessions_list.js +21 -4
  41. data/lib/samagotchi/web/public/stage_model.js +23 -2
  42. data/lib/samagotchi/web/public/stage_view.js +16 -11
  43. data/lib/samagotchi/web/public/task_stop.js +11 -2
  44. data/lib/samagotchi/web/public/timing.js +12 -7
  45. data/lib/samagotchi/web/public/turn_events.js +3 -1
  46. data/lib/samagotchi/web/public/turn_model.js +29 -15
  47. data/lib/samagotchi/web/public/turn_view.js +70 -11
  48. data/lib/samagotchi/worker.rb +24 -7
  49. metadata +2 -1
@@ -197,7 +197,8 @@ module Samagotchi
197
197
  emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
198
198
  stopped_by: turn.cancel_controller&.stopped_by)
199
199
  return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
200
- partial_assistant_text: turn.buffer)
200
+ partial_assistant_text: turn.buffer,
201
+ streamed_thinking: turn.streamed_thinking)
201
202
  end
202
203
  finish(turn)
203
204
  rescue StandardError => e
@@ -239,8 +240,8 @@ module Samagotchi
239
240
  # context tracker and the retry budget, the tool activity, the visible
240
241
  # text streamed so far (salvaged on a cancel), the Qwen recovery state,
241
242
  # the prefill, and what the caller gave.
242
- Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
243
- :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
243
+ Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :streamed_thinking, :qwen_attempts,
244
+ :qwen_partial, :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
244
245
  :cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
245
246
  # One request: the prompt and its images as sent, the images' token
246
247
  # estimate, and the window it was measured against.
@@ -258,7 +259,8 @@ module Samagotchi
258
259
  conversation = prepare_conversation(messages)
259
260
  Turn.new(
260
261
  conversation: conversation, context: ContextStatus.new(conversation: conversation),
261
- empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
262
+ empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
263
+ qwen_attempts: 0, qwen_partial: nil,
262
264
  # Qwen with thinking off: an empty thought after the cue, so the model
263
265
  # answers at once. Kept in the turn's model messages, so each tool-loop
264
266
  # prompt starts with what the server already has cached.
@@ -329,6 +331,9 @@ module Samagotchi
329
331
  generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
330
332
  fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
331
333
  buffer_mark = turn.buffer.length
334
+ # A cancel salvages only this generation's thinking: the earlier
335
+ # steps' is on their own messages already.
336
+ turn.streamed_thinking = +""
332
337
  generation.response = with_generation(turn.cancel_controller) do |generation_controller|
333
338
  request_generation(
334
339
  request.prompt,
@@ -342,6 +347,7 @@ module Samagotchi
342
347
  generation.usage = nil
343
348
  generation.cache = nil
344
349
  generation.streamed_thinking = 0
350
+ turn.streamed_thinking = +""
345
351
  generation.finish_reason = nil
346
352
  turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
347
353
  },
@@ -368,17 +374,25 @@ module Samagotchi
368
374
  generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
369
375
  split = stream_splitter.feed(chunk[:content])
370
376
  turn.buffer << split[:text]
377
+ turn.streamed_thinking << split[:thinking].to_s
371
378
  generation.streamed_thinking += split[:thinking].to_s.length
372
379
  return unless turn.on_stream_event
373
380
 
374
381
  event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
375
- thinking: split[:thinking], payload: chunk[:payload] }
382
+ thinking: split[:thinking], payload: event_payload(chunk[:payload]) }
376
383
  # A chunk of a tool call (its bytes are dropped from both lanes): a
377
384
  # steer doesn't cut this generation (Engine#cut_for_steer).
378
385
  event[:tool_call] = true if split[:tool]
379
386
  emit(turn, **event)
380
387
  end
381
388
 
389
+ # The payload a :generation_chunk event carries: llama.cpp's last chunk
390
+ # echoes the whole rendered prompt, which nothing reads and every SSE
391
+ # client would get (the kernel's own reads keep the full payload).
392
+ def event_payload(payload)
393
+ payload.is_a?(Hash) ? payload.except("prompt") : payload
394
+ end
395
+
382
396
  # The payload's prompt-cache counts, kept as the largest seen.
383
397
  def note_cache_counts(generation, payload)
384
398
  counts = TokenUsage.from_payload(payload)
@@ -618,7 +632,7 @@ module Samagotchi
618
632
  ModelProfile.required_model_name(override)
619
633
  end
620
634
 
621
- def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
635
+ def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "", streamed_thinking: "")
622
636
  partial = partial_assistant_text.to_s.strip
623
637
  conversation = duplicate_conversation(conversation)
624
638
  # Salvage the already-streamed visible reply (thought/tool_call lanes
@@ -626,7 +640,13 @@ module Samagotchi
626
640
  # cannot leak) so a follow-up steering message continues with the model's
627
641
  # half-finished work in context instead of losing it.
628
642
  unless partial.empty?
629
- conversation << { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
643
+ message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
644
+ # The thinking this generation streamed goes with it (the chat loop's
645
+ # with_thinking key, which MessageParts reads and the prompt doesn't):
646
+ # without it, reloading the cut step shows the empty answer it isn't.
647
+ thinking = streamed_thinking.to_s
648
+ message[:thinking] = thinking unless thinking.strip.empty?
649
+ conversation << message
630
650
  end
631
651
  LLM::ModelResult.new(
632
652
  text: "",
@@ -338,8 +338,8 @@ module Samagotchi
338
338
  return canceled(iteration, @cancel_controller.reason) if @cancel_controller&.cancelled?
339
339
 
340
340
  inject_pending_input(iteration)
341
- response, partial = generate(iteration)
342
- return canceled(iteration, response, partial) if partial
341
+ response, partial, thought = generate(iteration)
342
+ return canceled(iteration, response, partial, thought) if partial
343
343
 
344
344
  if response.cut
345
345
  outcome = after_cut(iteration, response)
@@ -468,7 +468,7 @@ module Samagotchi
468
468
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
469
469
  [response, nil]
470
470
  rescue RequestCancelled => e
471
- [e.reason, streamed]
471
+ [e.reason, streamed, thought]
472
472
  end
473
473
 
474
474
  # The request runs under the generation's own controller (the turn
@@ -593,11 +593,17 @@ module Samagotchi
593
593
  end
594
594
 
595
595
  # The text streamed before the cancel stays, marked [interrupted],
596
- # as the native loop's salvage does.
597
- def canceled(iteration, reason, partial = "")
596
+ # as the native loop's salvage does; the reasoning this generation
597
+ # streamed rides along on the message (with_thinking's key).
598
+ def canceled(iteration, reason, partial = "", thinking = "")
598
599
  emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
599
600
  visible = @loop.strip_model_thought(partial.to_s).strip
600
- @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
601
+ unless visible.empty?
602
+ message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true }
603
+ thought = thinking.to_s
604
+ message[:thinking] = thought unless thought.strip.empty?
605
+ @conversation << message
606
+ end
601
607
  conversation = @loop.plain(@conversation)
602
608
  conversation.last[:interrupted] = true unless visible.empty?
603
609
  ModelResult.new(text: "", conversation: conversation, canceled: true,
@@ -303,6 +303,7 @@ module Samagotchi
303
303
  # Like the rules, the bundle's plugin replaces an earlier one.
304
304
  plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
305
305
  warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
306
+ warn_rules_requires_chi(manifest, incoming_rules)
306
307
 
307
308
  # Files the previous version shipped and this one doesn't.
308
309
  if @upgrade && existing_provenance && existing_provenance[:files]
@@ -559,6 +560,19 @@ module Samagotchi
559
560
  end
560
561
  end
561
562
 
563
+ # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
564
+ # requires_chi this chi doesn't meet, and a session that never loaded
565
+ # them records a required load failure (the gate refuses guarded calls
566
+ # until chi is updated): say so at install, as the hooks warning does.
567
+ def warn_rules_requires_chi(manifest, rule_files)
568
+ return if manifest.nil? || rule_files.empty?
569
+
570
+ if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
571
+ @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
572
+ "(chi update), guarded tool calls are refused"
573
+ end
574
+ end
575
+
562
576
  # Copy the manifest's plugin file into the bundle's plugin/ dir,
563
577
  # warning when its sha256 differs from the declared one or this chi
564
578
  # doesn't meet requires_chi (the Engine then won't load it).
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ require_relative "atomic_file"
7
+ require_relative "log"
8
+ require_relative "paths"
9
+
10
+ module Samagotchi
11
+ # The model ids each host last listed, saved in
12
+ # <state dir>/model_lists.json for the processes that spawn a worker but
13
+ # never list a host themselves: `chi send --new --model` and the
14
+ # `delegate` tool warn about an unknown model id up front from it
15
+ # (ModelProfile.model_warning) instead of only the worker's first turn
16
+ # failing. It is a cache of the hosts' own lists, so it never raises: no
17
+ # file, an unreadable one or bad JSON reads as no list at all. Only a
18
+ # successful listing writes (a host that is down keeps the ids we know),
19
+ # and a list older than TTL_SECONDS is no evidence about the host any
20
+ # more.
21
+ #
22
+ # { "<host name>" => { "ids" => ["…"], "at" => <epoch seconds> } }
23
+ module ModelListStore
24
+ FILE = "model_lists.json"
25
+ # When a saved list is too old to say what a host serves: a model may
26
+ # have been added or removed since.
27
+ TTL_SECONDS = 7 * 24 * 60 * 60
28
+
29
+ # One host's saved list: the ids it listed and when (+at+, epoch
30
+ # seconds).
31
+ Saved = Data.define(:host, :ids, :at) do
32
+ def age(now: Time.now.to_i) = now.to_i - at.to_i
33
+
34
+ # Too old to check an id against (ModelProfile.model_warning): a model
35
+ # may have been added or removed since.
36
+ def stale?(now: Time.now.to_i) = age(now: now) > ModelListStore::TTL_SECONDS
37
+
38
+ # A host's own list spells its ids, and resolution matches them by
39
+ # case (the registry's model index is downcased): so does this.
40
+ def known?(id) = ids.any? { |known| known.casecmp?(id.to_s.strip) }
41
+ end
42
+
43
+ module_function
44
+
45
+ # @return [String] <state home>/samagotchi/model_lists.json
46
+ def path(env: ENV)
47
+ File.join(Paths.state_dir(env: env), FILE)
48
+ end
49
+
50
+ # Every saved list by host name, the lists as [Saved] (empty without a
51
+ # file, with bad JSON, or with nothing readable in it).
52
+ # @return [Hash{String => Saved}]
53
+ def read(env: ENV)
54
+ all(env: env).each_with_object({}) do |(name, entry), acc|
55
+ list = saved_from(name, entry)
56
+ acc[name] = list if list
57
+ end
58
+ end
59
+
60
+ # One host's saved list.
61
+ # @return [Saved, nil] nil without a file, with bad JSON, or with no
62
+ # entry for the host
63
+ def find(host, env: ENV)
64
+ read(env: env)[host.to_s.strip.downcase]
65
+ end
66
+
67
+ # Save the ids +host+ just listed, keeping every other host's entry.
68
+ # Best effort: a store that can't be written never fails the listing
69
+ # that found the ids. An empty list is not saved (it is no evidence the
70
+ # host serves nothing: keep the ids we know).
71
+ # @return [Saved, nil] what was saved, nil when nothing was
72
+ def save(host, ids, at: Time.now.to_i, env: ENV)
73
+ name = host.to_s.strip.downcase
74
+ list = Array(ids).map { |id| id.to_s.strip }.reject(&:empty?).uniq
75
+ return nil if name.empty? || list.empty?
76
+
77
+ file = path(env: env)
78
+ FileUtils.mkdir_p(File.dirname(file))
79
+ # Under the lock, so two workers listing a host at once (or two
80
+ # threads of one registry) never drop each other's entries.
81
+ File.open("#{file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
82
+ lock.flock(File::LOCK_EX)
83
+ entries = all(env: env)
84
+ entries[name] = { "ids" => list, "at" => at.to_i }
85
+ AtomicFile.write(file, JSON.pretty_generate(entries) + "\n")
86
+ end
87
+ Saved.new(host: name, ids: list, at: at.to_i)
88
+ rescue StandardError => e
89
+ Log.warn(:model, "model_list_save_failed", host: host.to_s, error: e.class.name, msg: e.message.to_s[0, 200])
90
+ nil
91
+ end
92
+
93
+ # The whole file, host names downcased: {name => entry}.
94
+ # @return [Hash]
95
+ def all(env: ENV)
96
+ data = JSON.parse(File.read(path(env: env)))
97
+ return {} unless data.is_a?(Hash)
98
+
99
+ data.each_with_object({}) do |(name, entry), acc|
100
+ acc[name.to_s.strip.downcase] = entry if entry.is_a?(Hash)
101
+ end
102
+ rescue StandardError
103
+ {}
104
+ end
105
+
106
+ # One +entry+ of the file as a Saved, or nil when it holds no ids or no
107
+ # time.
108
+ def saved_from(name, entry)
109
+ ids = Array(entry["ids"]).map { |id| id.to_s.strip }.reject(&:empty?)
110
+ at = entry["at"].to_i
111
+ return nil if ids.empty? || at <= 0
112
+
113
+ Saved.new(host: name, ids: ids, at: at)
114
+ end
115
+ private_class_method :saved_from
116
+ end
117
+ end
@@ -179,6 +179,49 @@ module Samagotchi
179
179
  raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
180
180
  end
181
181
 
182
+ # A warning when +model_name+ resolves to a model id a host's saved
183
+ # list doesn't have (ModelListStore: what a `chi models`, a /models or
184
+ # the web's GET /api/models last listed for that host), else nil.
185
+ # Called where a session is spawned without listing the hosts itself
186
+ # (`chi send --new --model`, the delegate tool), so a typo is
187
+ # named before the worker's first turn fails on it. Only a warning: some
188
+ # hosts serve ids they don't list (a one-model llama.cpp server takes any
189
+ # name, OpenRouter's :nitro variants), so the session starts anyway.
190
+ # Never asks a host.
191
+ #
192
+ # Only a ref that names a host is checked (its alias resolved first, as
193
+ # ModelRef.parse does), and only against that host's own list: a bare id
194
+ # goes to whichever host lists it (HostRegistry#host_for_model), so no
195
+ # single list can judge it. Nothing is checked either without a saved
196
+ # list for the host or with one older than ModelListStore::TTL_SECONDS
197
+ # (a week: the host may serve different models by now).
198
+ # @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
199
+ # entries); config.yml's by default
200
+ # @param lists [#read] ModelListStore by default
201
+ # @return [String, nil]
202
+ def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil)
203
+ require_relative "config"
204
+ require_relative "model_list_store"
205
+ hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
206
+ lists ||= Samagotchi::ModelListStore
207
+ parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
208
+ host = parsed.host_name
209
+ return nil unless host
210
+
211
+ list = lists.read(env: env)[host.to_s.strip.downcase]
212
+ return nil if list.nil? || list.stale? || list.known?(parsed.id)
213
+
214
+ unknown_model_message(parsed.id, host, list.ids)
215
+ end
216
+
217
+ # The warning for an id the host doesn't list, with up to three close
218
+ # ids (Config.near_names) when there are any.
219
+ def self.unknown_model_message(id, host, ids)
220
+ near = Samagotchi::Config.near_names(id, ids).first(3)
221
+ hint = near.empty? ? "" : " (did you mean: #{near.join(", ")}?)"
222
+ "host '#{host}' doesn't list model '#{id}'#{hint}; started it anyway; `chi models` lists what the hosts serve"
223
+ end
224
+
182
225
  # The prefix of "box:x" when box is a host config.yml has with
183
226
  # enabled: false (left out of +hosts+), else nil.
184
227
  def self.disabled_host_prefix(ref, hosts, env)
@@ -241,9 +241,10 @@ module Samagotchi
241
241
 
242
242
  # A worker session like the web start page's: saved as running with the
243
243
  # message before its worker spawns, so lists and the web show it at
244
- # once. The full id, so a script can pass it on. Only the model's host
245
- # is checked here (in spawn_session, as in the web): a wrong model id
246
- # fails in the worker.
244
+ # once. The full id, so a script can pass it on. The model's host is
245
+ # checked here (in spawn_session, as in the web): an unknown one is
246
+ # refused before anything is spawned; an id the host's saved model list
247
+ # doesn't have starts with a warning on stderr.
247
248
  # @return [Integer] the exit status
248
249
  def run_new(prompt, options)
249
250
  images = !@images.empty?
@@ -264,6 +265,7 @@ module Samagotchi
264
265
  error_line("chi send: could not start a session: #{e.message}")
265
266
  return 1
266
267
  end
268
+ error_line("chi send: warning: #{session.model_warning}") if session.model_warning
267
269
  if through_worker
268
270
  return 1 unless deliver_new(session, prompt)
269
271
  else
@@ -79,6 +79,11 @@ module Samagotchi
79
79
  # sets it); not saved.
80
80
  attr_accessor :seed_images_dropped
81
81
 
82
+ # The warning for a model id its host's saved list doesn't have
83
+ # (ModelProfile.model_warning; SessionManager.spawn_session sets it);
84
+ # not saved.
85
+ attr_accessor :model_warning
86
+
82
87
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
83
88
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
84
89
  first_preview: "", test_run: false, pending_question: nil,
@@ -5,6 +5,7 @@ require_relative "model_profile"
5
5
  require_relative "served_model"
6
6
  require_relative "turn_flow"
7
7
  require_relative "tools/execute"
8
+ require_relative "tools/builtins"
8
9
  require_relative "commands/registry"
9
10
 
10
11
  module Samagotchi
@@ -207,12 +208,15 @@ module Samagotchi
207
208
  end
208
209
 
209
210
  # The output goes into the conversation for the next turn. Not saved
210
- # here (as in the REPL, the next turn saves it).
211
+ # here (as in the REPL, the next turn saves it). No parent-session
212
+ # marker: the user runs this, not the model. A worker's `chi --model X`
213
+ # default is unset, though: it was that run's, not the default of a chi
214
+ # run here (Tools::Builtins.cli_model_unset).
211
215
  def shell(text)
212
216
  command = text.delete_prefix(SHELL_BANG_PREFIX).strip
213
217
  return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
214
218
 
215
- output = Samagotchi::Tools::Execute.call(command)
219
+ output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
216
220
  @engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
217
221
  # Rolling back past this would silently drop the command output.
218
222
  @turn_flow.note_conversation_changed
@@ -130,14 +130,20 @@ module Samagotchi
130
130
  # @param images_from [String, nil] the session dir the seed's images are in
131
131
  # @param title [String, nil] what the lists show before the first turn
132
132
  # (the prompt's preview by default, else the seed's first user message)
133
- # @return [Session] with #seed_images_dropped: refs whose file was gone
133
+ # @return [Session] with #seed_images_dropped: refs whose file was gone,
134
+ # and #model_warning: an id its host's saved list doesn't have
134
135
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
135
136
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
136
137
  title: nil)
137
138
  sd = state_dir || Session.default_state_dir
138
139
  # The resolved ref is stored (a resumed session keeps its model when an
139
- # alias is retargeted), with the name as typed beside it.
140
+ # alias is retargeted), with the name as typed beside it. A wrong host
141
+ # refuses here, before anything is saved or spawned; a model id the
142
+ # host's saved list doesn't have (ModelListStore) only warns
143
+ # (#model_warning on the session), as some hosts serve ids they don't
144
+ # list.
140
145
  typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
146
+ model_warning = Samagotchi::ModelProfile.model_warning(typed)
141
147
  ref = ConfigFile.model_ref(typed).ref
142
148
  session = Session.new_session(
143
149
  mode: mode,
@@ -149,6 +155,7 @@ module Samagotchi
149
155
  parent_id: parent_id,
150
156
  messages: messages
151
157
  )
158
+ session.model_warning = model_warning
152
159
  # With no prompt there is no first turn to run (an attaching UI sends
153
160
  # the prompts), so the session starts idle.
154
161
  session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
@@ -481,9 +481,12 @@ module Samagotchi
481
481
  # A turn that failed mid-stream never saw its generation end:
482
482
  # what it got to still counts.
483
483
  close_generation if @turn.gen_open
484
- # A call still running when the turn ends (a cancel, a failure)
485
- # never completes: its record says so, as the count already has it.
486
- @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, "canceled") }
484
+ # A call still running when the turn ends never completes: its
485
+ # record says so, as the count already has it. A cancel stopped it
486
+ # ("stopped", as ToolActivity names a call the Stop cut); a
487
+ # failure left it "canceled".
488
+ cut = status == "canceled" ? "stopped" : "canceled"
489
+ @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, cut) }
487
490
  @turn.tool_calls_by_id.clear
488
491
  finished_at = now
489
492
  record = {
@@ -226,19 +226,22 @@ module Samagotchi
226
226
  # A cancel's reason as the UIs name it (anything else as it is);
227
227
  # spec/shared/labels_matrix.json pins it for both.
228
228
  CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
229
+ # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
230
+ # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
231
+ STOP_MARK = "■"
229
232
  # Under a canceled prompt turn (a canceled continue is back where it
230
233
  # started): its partial progress stays in the conversation.
231
234
  ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
232
235
 
233
- # "✕ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
234
- # (+by+, turn_canceled's cancelled_by): "✕ turn stopped by loop-guard"
236
+ # "■ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
237
+ # (+by+, turn_canceled's cancelled_by): "■ turn stopped by loop-guard"
235
238
  def turn_canceled_line(reason, duration_ms, by: nil)
236
239
  if reason.to_s == "hook" && !by.to_s.empty?
237
- return "#{paint("✕", 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
240
+ return "#{paint(STOP_MARK, 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
238
241
  end
239
242
 
240
243
  label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
241
- "#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
244
+ "#{paint(STOP_MARK, 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
242
245
  end
243
246
 
244
247
  # "✕ turn failed: <summary> · 2.0s"
@@ -16,6 +16,7 @@ require_relative "host_registry"
16
16
  require_relative "session"
17
17
  require_relative "owner_lock"
18
18
  require_relative "bridge/card_store"
19
+ require_relative "bridge/pending_card"
19
20
  require_relative "engine"
20
21
  require_relative "guardrails/parent_approvals"
21
22
  require_relative "tools/memory"
@@ -298,7 +299,10 @@ module Samagotchi
298
299
  ensure
299
300
  # However it ends (the early returns too; the Engine was built in
300
301
  # #initialize): the anytime commands finish, the plugins' services
301
- # stop (a server process).
302
+ # stop (a server process). Nothing resolves an open card any more, so
303
+ # its pending_card.json goes (a scratch session's folder is deleted
304
+ # below anyway; the others stay).
305
+ clear_pending_card
302
306
  @engine&.shutdown
303
307
  # A scratch session goes on every way out: /exit, Ctrl-D, an error,
304
308
  # SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
@@ -308,6 +312,17 @@ module Samagotchi
308
312
 
309
313
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
310
314
 
315
+ # The session is over for this process (its exits and the ensure below):
316
+ # nothing will resolve the open card any more, so its pending_card.json
317
+ # goes (the Engine's TURN_END would clear it, but the REPL may end
318
+ # mid-turn).
319
+ def clear_pending_card
320
+ return unless @pending_card_handle
321
+
322
+ @pending_card_handle.observer.clear
323
+ @pending_card_handle = nil
324
+ end
325
+
311
326
  # A -p --non-interactive turn that failed (bin/chi exits 1): the error,
312
327
  # then how to go on. The Engine saved the failed turn with its prompt,
313
328
  # so the session is kept and `chi --resume` takes it up again.
@@ -415,13 +430,28 @@ module Samagotchi
415
430
  @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
416
431
  end
417
432
 
418
- # The session's cards.json, kept as a worker's Bridge keeps it (it owns
419
- # the session as a worker would): this REPL's kept turns count on from
420
- # the saved count, so a card an earlier worker showed stays that many
421
- # turns back, and its own cards and notices are saved for the next.
433
+ # The session's cards.json and pending_card.json, kept as a worker's
434
+ # Bridge keeps them (it owns the session as a worker would): this REPL's
435
+ # kept turns count on from the saved count, so a card an earlier worker
436
+ # showed stays that many turns back, and its own cards and notices are
437
+ # saved for the next; an open card with actions is on disk, where
438
+ # `chi web`'s session hub reads it to mark the session as waiting.
422
439
  def keep_cards(session)
423
440
  path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
424
441
  @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
442
+ keep_pending_card(session)
443
+ end
444
+
445
+ # A card a worker that died left isn't open: the file goes before the
446
+ # observer takes over, as Bridge#start does. Subscribed once: the REPL
447
+ # keeps one session, and a second observer would fold every card twice
448
+ # and clear the file at the first turn's end.
449
+ def keep_pending_card(session)
450
+ return if @pending_card_handle
451
+
452
+ pending = Bridge::PendingCard.new(Session.session_dir(session.id))
453
+ pending.clear
454
+ @pending_card_handle = @engine.subscribe(observer: pending)
425
455
  end
426
456
 
427
457
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
@@ -12,6 +12,7 @@ require_relative "tools/edit"
12
12
  require_relative "tools/task_create"
13
13
  require_relative "tools/task_get"
14
14
  require_relative "tools/task_list"
15
+ require_relative "tools/task_runtime"
15
16
  require_relative "tools/task_stop"
16
17
  require_relative "tools/task_wait"
17
18
  require_relative "tools/web_fetch"
@@ -62,11 +63,13 @@ module Samagotchi
62
63
  # command without its "cd … &&"): a file tool's path relative to +cwd+
63
64
  # when it is under it, a command's description (the model's few words),
64
65
  # else its first step and how many more (CommandSteps; its first line
65
- # when it has no steps), a memory's name;
66
- # nil for the other tools (a plugin's row keeps its preview, the
66
+ # when it has no steps), a memory's name, a task tool's task command
67
+ # (task_title); nil for the other tools (a plugin's row keeps its preview, the
67
68
  # params). +params+ stays the full key=value line (the TUI, the
68
69
  # guardrails).
69
70
  def tool_title(tool_name, call, cwd: nil)
71
+ return task_title(tool_name, call, cwd) if TASK_TOOLS.include?(tool_name)
72
+
70
73
  if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
71
74
  title = command_description(tool_name, call) || steps_title(call[:content])
72
75
  return title if title
@@ -90,19 +93,42 @@ module Samagotchi
90
93
  end
91
94
  end
92
95
 
96
+ TASK_TOOLS = [Tools::TaskWait::NAME, Tools::TaskGet::NAME, Tools::TaskStop::NAME].freeze
97
+
98
+ # A task_wait / task_get / task_stop row's title: the task's command
99
+ # (its record under +cwd+, the process's directory when nil) as a
100
+ # command's title, and for a wait how long it waits ("npm test · up to
101
+ # 600s"). nil when there is no record: the row keeps its id.
102
+ def task_title(tool_name, call, cwd)
103
+ command = Tools::TaskRuntime.command_of(call[:content], root: cwd)
104
+ return nil unless command
105
+
106
+ suffix = ""
107
+ if tool_name == Tools::TaskWait::NAME
108
+ timeout = call[:timeout].to_s.strip
109
+ suffix = " · up to #{timeout.empty? ? Tools::TaskWait::TIMEOUT_DEFAULT : timeout.to_i}s"
110
+ end
111
+ room = TOOL_ACTIVITY_PREVIEW_LIMIT - suffix.length
112
+ text = steps_title(command, room) || command_title(command).gsub(/\s+/, " ")
113
+ return nil if text.empty?
114
+
115
+ text = "#{text[0, room - 1]}…" if text.length > room
116
+ "#{text}#{suffix}"
117
+ end
118
+
93
119
  # A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
94
120
  # eats the line.
95
121
  LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
96
122
 
97
- # "<first step> +N" (N the steps after it), cut to fit
98
- # TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
99
- def steps_title(command)
123
+ # "<first step> +N" (N the steps after it), cut to fit +limit+
124
+ # (TOOL_ACTIVITY_PREVIEW_LIMIT); nil when the command has no steps.
125
+ def steps_title(command, limit = TOOL_ACTIVITY_PREVIEW_LIMIT)
100
126
  steps = CommandSteps.parse(command)&.steps
101
127
  return nil unless steps
102
128
 
103
129
  first = steps.first.text.gsub(/\s+/, " ")
104
130
  more = steps.size > 1 ? " +#{steps.size - 1}" : ""
105
- room = TOOL_ACTIVITY_PREVIEW_LIMIT - more.length
131
+ room = limit - more.length
106
132
  first = "#{first[0, room - 1]}…" if first.length > room
107
133
  "#{first}#{more}"
108
134
  end
@@ -150,10 +176,14 @@ module Samagotchi
150
176
  # "error" for an "Error:" result, and for an execute whose command
151
177
  # exited non-zero (its last "exit: N" line; no number: killed by a
152
178
  # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
153
- # task_wait the user's Stop ended (the task itself runs on), and for one
154
- # whose task was stopped (the stop-task button, or the model's task_stop).
179
+ # task_wait the user's Stop ended (the task itself runs on), for one
180
+ # whose task was stopped (the stop-task button, or the model's task_stop),
181
+ # and for a command the user's Stop killed or kept from starting: a
182
+ # cancel, not a failure (its text still starts "Error:" for the model).
155
183
  def tool_activity_status(result, tool_name = nil)
156
184
  text = result.to_s
185
+ return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
186
+ return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
157
187
  return "error" if text.start_with?("Error:")
158
188
  return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
159
189
  return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)