samagotchi 0.22.0 → 0.24.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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/bin/chi +9 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +13 -4
  6. data/docs/hooks.md +20 -6
  7. data/docs/plugins.md +1 -1
  8. data/docs/releasing.md +1 -1
  9. data/docs/sub-agent.md +4 -3
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +4 -2
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  13. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  14. data/lib/samagotchi/bootstrap_command.rb +2 -2
  15. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  16. data/lib/samagotchi/bridge/card_store.rb +4 -3
  17. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
  19. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +19 -9
  21. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  22. data/lib/samagotchi/bridge_client.rb +1 -1
  23. data/lib/samagotchi/bundle_command.rb +39 -29
  24. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
  28. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  29. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  32. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  36. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  37. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  38. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  39. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  40. data/lib/samagotchi/cancellation_controller.rb +1 -1
  41. data/lib/samagotchi/cli/command.rb +1 -1
  42. data/lib/samagotchi/client.rb +3 -5
  43. data/lib/samagotchi/commands/registry.rb +2 -2
  44. data/lib/samagotchi/config.rb +42 -14
  45. data/lib/samagotchi/context_note.rb +1 -1
  46. data/lib/samagotchi/context_status.rb +6 -6
  47. data/lib/samagotchi/continue_offer.rb +15 -8
  48. data/lib/samagotchi/debug_log.rb +1 -1
  49. data/lib/samagotchi/desktop/macos.rb +5 -4
  50. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  51. data/lib/samagotchi/engine.rb +97 -18
  52. data/lib/samagotchi/gem_update.rb +2 -1
  53. data/lib/samagotchi/generation_phase.rb +83 -0
  54. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  55. data/lib/samagotchi/guardrails/approval.rb +5 -5
  56. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  57. data/lib/samagotchi/guardrails/gate.rb +2 -2
  58. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  59. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  60. data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
  61. data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
  62. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  63. data/lib/samagotchi/hooks/loader.rb +18 -16
  64. data/lib/samagotchi/hooks/registry.rb +10 -2
  65. data/lib/samagotchi/host_registry.rb +1 -3
  66. data/lib/samagotchi/idle_recap.rb +22 -17
  67. data/lib/samagotchi/idle_reminders.rb +6 -3
  68. data/lib/samagotchi/image_store.rb +5 -2
  69. data/lib/samagotchi/kernel_loop.rb +50 -40
  70. data/lib/samagotchi/llm/chat_loop.rb +29 -16
  71. data/lib/samagotchi/llm/errors.rb +2 -2
  72. data/lib/samagotchi/llm/http.rb +12 -12
  73. data/lib/samagotchi/llm/openai_chat.rb +42 -28
  74. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  75. data/lib/samagotchi/log.rb +2 -2
  76. data/lib/samagotchi/log_subscriber.rb +5 -3
  77. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  78. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  79. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  80. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  81. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  82. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  83. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  84. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  85. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  86. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  87. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  88. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  89. data/lib/samagotchi/memory_bundle.rb +1 -0
  90. data/lib/samagotchi/model_overlay.rb +3 -5
  91. data/lib/samagotchi/model_profile.rb +12 -8
  92. data/lib/samagotchi/note_command.rb +2 -1
  93. data/lib/samagotchi/output_formatter.rb +18 -17
  94. data/lib/samagotchi/owner_lock.rb +2 -1
  95. data/lib/samagotchi/plugin/api.rb +8 -5
  96. data/lib/samagotchi/plugin/context.rb +1 -3
  97. data/lib/samagotchi/plugin/loader.rb +1 -1
  98. data/lib/samagotchi/plugin/service.rb +1 -1
  99. data/lib/samagotchi/plugin/sessions.rb +1 -0
  100. data/lib/samagotchi/plugin_tasks.rb +1 -1
  101. data/lib/samagotchi/prompt.rb +72 -13
  102. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  103. data/lib/samagotchi/question_desk.rb +7 -7
  104. data/lib/samagotchi/recap_setup.rb +1 -1
  105. data/lib/samagotchi/relay_desk.rb +1 -1
  106. data/lib/samagotchi/reminder_store.rb +2 -1
  107. data/lib/samagotchi/send_command.rb +4 -2
  108. data/lib/samagotchi/session.rb +2 -2
  109. data/lib/samagotchi/session_archive_command.rb +5 -1
  110. data/lib/samagotchi/session_commands.rb +1 -3
  111. data/lib/samagotchi/session_manager.rb +2 -4
  112. data/lib/samagotchi/session_metrics.rb +10 -10
  113. data/lib/samagotchi/session_observer.rb +3 -2
  114. data/lib/samagotchi/session_retention.rb +2 -2
  115. data/lib/samagotchi/sessions_command.rb +14 -5
  116. data/lib/samagotchi/steer.rb +103 -8
  117. data/lib/samagotchi/system_prompt.rb +19 -8
  118. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  119. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  120. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  121. data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
  122. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  123. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  124. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  125. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  126. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  127. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  128. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  129. data/lib/samagotchi/terminal_ui.rb +14 -5
  130. data/lib/samagotchi/text_diff.rb +12 -12
  131. data/lib/samagotchi/thinking.rb +6 -2
  132. data/lib/samagotchi/thought_stream_splitter.rb +15 -6
  133. data/lib/samagotchi/tool_activity.rb +1 -0
  134. data/lib/samagotchi/tool_call_parser.rb +13 -14
  135. data/lib/samagotchi/tool_declarations.rb +36 -35
  136. data/lib/samagotchi/tool_runner.rb +7 -0
  137. data/lib/samagotchi/tool_view.rb +40 -0
  138. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  139. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  140. data/lib/samagotchi/tools/delegate.rb +1 -1
  141. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  142. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  143. data/lib/samagotchi/tools/edit.rb +14 -13
  144. data/lib/samagotchi/tools/execute.rb +3 -5
  145. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  146. data/lib/samagotchi/tools/memory.rb +21 -22
  147. data/lib/samagotchi/tools/read.rb +7 -7
  148. data/lib/samagotchi/tools/task_create.rb +1 -1
  149. data/lib/samagotchi/tools/task_get.rb +1 -1
  150. data/lib/samagotchi/tools/task_list.rb +1 -1
  151. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  152. data/lib/samagotchi/tools/task_stop.rb +1 -1
  153. data/lib/samagotchi/tools/task_wait.rb +3 -4
  154. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  155. data/lib/samagotchi/tools/write.rb +1 -1
  156. data/lib/samagotchi/turn_flow.rb +8 -8
  157. data/lib/samagotchi/turn_note.rb +5 -1
  158. data/lib/samagotchi/version.rb +1 -1
  159. data/lib/samagotchi/vision_support.rb +2 -2
  160. data/lib/samagotchi/waiting_steer.rb +64 -0
  161. data/lib/samagotchi/web/app.rb +13 -6
  162. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  163. data/lib/samagotchi/web/message_parts.rb +5 -0
  164. data/lib/samagotchi/web/public/activity.js +5 -0
  165. data/lib/samagotchi/web/public/app.js +2 -1
  166. data/lib/samagotchi/web/public/command_view.js +28 -0
  167. data/lib/samagotchi/web/public/copy.js +7 -0
  168. data/lib/samagotchi/web/public/index.html +8 -0
  169. data/lib/samagotchi/web/public/stage_model.js +11 -4
  170. data/lib/samagotchi/web/public/stage_view.js +5 -2
  171. data/lib/samagotchi/web/public/turn_events.js +15 -1
  172. data/lib/samagotchi/web/public/turn_view.js +15 -6
  173. data/lib/samagotchi/web/qr.rb +7 -1
  174. data/lib/samagotchi/web/server.rb +6 -2
  175. data/lib/samagotchi/worker.rb +14 -3
  176. metadata +24 -20
@@ -101,8 +101,8 @@ module Samagotchi
101
101
  # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
102
102
  # @return [String] normalized answer JSON
103
103
  def request(payload)
104
- result = open_question({ **payload.slice(:question, :options, :header),
105
- multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform] })
104
+ result = open_question(payload.slice(:question, :options, :header)
105
+ .merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
106
106
  # Dismissed (the card's dismiss, Esc): an answer of its own, not a
107
107
  # tool failure the model learns to avoid the tool from.
108
108
  result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
@@ -288,6 +288,7 @@ module Samagotchi
288
288
  if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
289
289
  raise Refused, reason
290
290
  end
291
+
291
292
  if @standing
292
293
  # No waiter: cleared here, so a second answer finds none.
293
294
  standing = @standing
@@ -332,9 +333,7 @@ module Samagotchi
332
333
  true
333
334
  end
334
335
 
335
- def sync_handler=(block)
336
- @sync_handler = block
337
- end
336
+ attr_writer :sync_handler
338
337
 
339
338
  # @return [Boolean] a REPL answers inline (no wait loop)
340
339
  def sync_handler? = !@sync_handler.nil?
@@ -355,7 +354,7 @@ module Samagotchi
355
354
  next if id && pending[:id].to_s != id.to_s
356
355
  next if @answer || pending[:status].to_s != "pending"
357
356
  # Its poster closes it; nothing waits on a cancel.
358
- raise NotDismissable, "answer #{Array(pending[:options]).join(' or ')}" if @standing && id
357
+ raise NotDismissable, "answer #{Array(pending[:options]).join(" or ")}" if @standing && id
359
358
  next if @standing
360
359
 
361
360
  pending[:status] = "cancelled"
@@ -469,9 +468,10 @@ module Samagotchi
469
468
  def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
470
469
  opts = Array(pending[:options])
471
470
  invalid = sel.reject { |v| opts.include?(v) }
472
- raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})" unless invalid.empty?
471
+ raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
473
472
  raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
474
473
  raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
474
+
475
475
  indices = sel.map { |v| opts.index(v) }
476
476
  # The one whose answer settles the approval (Approval.settle, by index).
477
477
  if parent_setting
@@ -114,7 +114,7 @@ module Samagotchi
114
114
  return range if range
115
115
 
116
116
  default = IdleRecap::RecapPrompt::DEFAULT_SENTENCES
117
- Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join('-')}",
117
+ Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join("-")}",
118
118
  value: value.to_s)
119
119
  default
120
120
  end
@@ -35,7 +35,7 @@ module Samagotchi
35
35
  # The parent's user (or a parent agent) answered the relay card.
36
36
  # @param by [String] "user" or "parent_agent"
37
37
  # @return [Boolean] false when the relay isn't open (first answer wins)
38
- def record(id, selected_indices: [], freeform: nil, dismissed: false, by:)
38
+ def record(id, by:, selected_indices: [], freeform: nil, dismissed: false)
39
39
  @mutex.synchronize do
40
40
  relay = @relays[id.to_s]
41
41
  next false unless relay && relay[:state] == "open"
@@ -81,7 +81,7 @@ module Samagotchi
81
81
  @mutex.synchronize do
82
82
  return "No active reminders." if @reminders.empty?
83
83
 
84
- lines = @reminders.map do |name, r|
84
+ lines = @reminders.map do |_name, r|
85
85
  " #{r[:name]} (interval: #{r[:interval_minutes]}m, id: #{r[:id]})"
86
86
  end
87
87
  "Active reminders:\n" + lines.join("\n")
@@ -98,6 +98,7 @@ module Samagotchi
98
98
  @mutex.synchronize do
99
99
  @reminders.each_value.filter_map do |r|
100
100
  next unless r[:next_fire_at] && now >= r[:next_fire_at]
101
+
101
102
  { name: r[:name], description: r[:description], interval_minutes: r[:interval_minutes] }
102
103
  end.freeze
103
104
  end
@@ -197,6 +197,7 @@ module Samagotchi
197
197
  return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
198
198
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
199
199
  return new_options(options) if options[:new]
200
+
200
201
  %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
201
202
  return usage_error("give session ids") if options[:ids].empty?
202
203
 
@@ -229,6 +230,7 @@ module Samagotchi
229
230
  # caller never closes still hangs, as it would for cat.)
230
231
  def read_stdin
231
232
  return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
233
+
232
234
  if @stdin.respond_to?(:stat)
233
235
  stat = @stdin.stat
234
236
  return nil unless stat.pipe? || stat.file?
@@ -328,8 +330,8 @@ module Samagotchi
328
330
 
329
331
  # The session as it was before the message went in (ReplyWait's
330
332
  # baseline).
331
- def baseline_of(session, **options)
332
- ReplyWait.baseline_of(session, **options)
333
+ def baseline_of(session, **)
334
+ ReplyWait.baseline_of(session, **)
333
335
  end
334
336
 
335
337
  # A new idle session's first turn, with the images. Its worker's Bridge
@@ -35,8 +35,8 @@ module Samagotchi
35
35
  SORT_ORDERS = %w[asc desc].freeze
36
36
 
37
37
  attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
38
- :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
39
- :pending_question, :used_memory_names
38
+ :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
39
+ :pending_question, :used_memory_names
40
40
  # Memories the session was started with (--memory) and memories hidden
41
41
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
42
42
  # Names as given; the engine normalizes them.
@@ -47,7 +47,11 @@ module Samagotchi
47
47
  bad = @argv.find { |arg| arg.start_with?("-") }
48
48
  return usage_error(bad ? "unknown option #{bad}" : "give session ids") if bad || @argv.empty?
49
49
 
50
- @argv.uniq.map { |given| @action == "archive" ? archive(given) : unarchive(given) }.all? ? 0 : 1
50
+ if @argv.uniq.map { |given| @action == "archive" ? archive(given) : unarchive(given) }.all?
51
+ 0
52
+ else
53
+ 1
54
+ end
51
55
  end
52
56
 
53
57
  private
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  require_relative "config"
6
4
  require_relative "model_profile"
7
5
  require_relative "served_model"
@@ -65,7 +63,7 @@ module Samagotchi
65
63
  guardrails(text.delete_prefix(GUARDRAILS_COMMAND).strip)
66
64
  end
67
65
  registry.register(MODEL_COMMAND, "show or switch the model",
68
- match: ->(text) { text.match?(/\A\/model(?:\s+.*)?\z/) }) { |text| model(text) }
66
+ match: ->(text) { text.match?(%r{\A/model(?:\s+.*)?\z}) }) { |text| model(text) }
69
67
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
70
68
  registry.register("/stats", "show the session's stats", local: true)
71
69
  registry.register("/recap", "show the session's recap", local: true)
@@ -1,4 +1,3 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require "fileutils"
@@ -272,7 +271,7 @@ module Samagotchi
272
271
  root = cwd && folder_path(cwd)
273
272
  roots = {}
274
273
  summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
275
- include_archived: include_archived).lazy
274
+ include_archived: include_archived).lazy
276
275
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
277
276
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
278
277
  .filter_map do |s|
@@ -703,7 +702,7 @@ module Samagotchi
703
702
  Dir.children(dir).all? do |name|
704
703
  path = File.join(dir, name)
705
704
  if EMPTY_SKELETON_DIRS.include?(name)
706
- File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
705
+ File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
707
706
  elsif name == "analytics.json"
708
707
  Array(JSON.parse(File.read(path))["turn_records"]).empty?
709
708
  # Cards and notices from before any turn (Bridge::CardStore).
@@ -1054,4 +1053,3 @@ module Samagotchi
1054
1053
  end
1055
1054
  end
1056
1055
  end
1057
-
@@ -137,9 +137,9 @@ module Samagotchi
137
137
 
138
138
  tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
139
139
  count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
140
- SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.("prompt_sum"),
141
- completion_sum: count.("completion_sum"), cached_sum: count.("cached_sum"),
142
- reasoning_sum: count.("reasoning_sum"), cost_sum: count.("cost_sum"))
140
+ SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.call("prompt_sum"),
141
+ completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
142
+ reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"))
143
143
  rescue JSON::ParserError, SystemCallError, TypeError
144
144
  nil
145
145
  end
@@ -379,15 +379,15 @@ module Samagotchi
379
379
  # Caller holds the mutex.
380
380
  def tokens_block(totals, turn)
381
381
  add = ->(key) { totals[key] + (turn&.public_send(key) || 0) }
382
- decode_ms = add.(:decode_ms_sum)
383
- decode_tokens = add.(:decode_tokens_sum)
382
+ decode_ms = add.call(:decode_ms_sum)
383
+ decode_tokens = add.call(:decode_tokens_sum)
384
384
  speed_turn = turn&.last_decode_tps ? turn : nil
385
385
  {
386
- prompt_sum: add.(:prompt_sum),
387
- completion_sum: add.(:completion_sum),
388
- cached_sum: add.(:cached_sum),
389
- reasoning_sum: add.(:reasoning_sum),
390
- cost_sum: add.(:cost_sum),
386
+ prompt_sum: add.call(:prompt_sum),
387
+ completion_sum: add.call(:completion_sum),
388
+ cached_sum: add.call(:cached_sum),
389
+ reasoning_sum: add.call(:reasoning_sum),
390
+ cost_sum: add.call(:cost_sum),
391
391
  decode_ms_sum: decode_ms.round,
392
392
  decode_tokens_sum: decode_tokens,
393
393
  avg_decode_tps: decode_ms.positive? ? (decode_tokens * 1000.0 / decode_ms).round(1) : nil,
@@ -70,6 +70,7 @@ module Samagotchi
70
70
  # already-unsubscribed never raises)
71
71
  def unsubscribe(handle:)
72
72
  return false unless handle.is_a?(SubscribedObserver)
73
+
73
74
  @mutex.synchronize { !!@observers.delete(handle) }
74
75
  end
75
76
 
@@ -110,8 +111,8 @@ module Samagotchi
110
111
  # Run the block while no event is being numbered or delivered, so it sees
111
112
  # (and can change) state consistently with the event log. Reentrant: the
112
113
  # block may notify. Keep it short; every emitter waits for it.
113
- def synchronize(&block)
114
- @mutex.synchronize(&block)
114
+ def synchronize(&)
115
+ @mutex.synchronize(&)
115
116
  end
116
117
  end
117
118
  end
@@ -171,7 +171,7 @@ module Samagotchi
171
171
  end
172
172
 
173
173
  now = Time.now
174
- cutoff = days.to_i.positive? ? now - days.to_i * 86_400 : nil
174
+ cutoff = days.to_i.positive? ? now - (days.to_i * 86_400) : nil
175
175
  max = max_count.to_i
176
176
 
177
177
  deleted = []
@@ -249,7 +249,7 @@ module Samagotchi
249
249
  begin
250
250
  FileUtils.rm_f(path)
251
251
  sidecar = File.join(state_dir, session.id)
252
- FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
252
+ FileUtils.rm_rf(sidecar)
253
253
  PluginSessionState.remove(session.id)
254
254
  deleted << session.id
255
255
  rescue StandardError
@@ -118,6 +118,7 @@ module Samagotchi
118
118
  return usage_error("unknown format #{format.inspect}: use --format text|json|tsv")
119
119
  end
120
120
  return usage_error("unknown scope #{scope.inspect}: use --scope=project|all") unless scope.nil? || %w[project all].include?(scope)
121
+
121
122
  # The current git project's sessions (Samagotchi::ProjectScope), like
122
123
  # chi web's; --scope=all, a folder in no repo or an explicit --cwd: every
123
124
  # project's.
@@ -175,13 +176,17 @@ module Samagotchi
175
176
  end
176
177
  # A delegated session points at its parent.
177
178
  child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
178
- flag = summary[:scratch] ? " [scratch]" : (summary[:test_run] ? " [test]" : "")
179
+ flag = if summary[:scratch]
180
+ " [scratch]"
181
+ else
182
+ (summary[:test_run] ? " [test]" : "")
183
+ end
179
184
  flag += " [archived]" if summary[:archived]
180
185
  ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
181
186
  @stdout.puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
182
187
  end
183
188
  if project
184
- @stdout.puts "#{summaries.empty? ? "" : "\n"}#{scope_note.call(summaries.size)}"
189
+ @stdout.puts "#{"\n" unless summaries.empty?}#{scope_note.call(summaries.size)}"
185
190
  else
186
191
  @stdout.puts summaries.empty? ? "No sessions." : "\n#{summaries.size} session(s)"
187
192
  end
@@ -204,7 +209,11 @@ module Samagotchi
204
209
  end
205
210
  # A delegated session points at its parent: ↳ <parent's short id>.
206
211
  row = lambda do |s|
207
- flag = s.scratch ? " [scratch]" : (s.test_run ? " [test]" : "")
212
+ flag = if s.scratch
213
+ " [scratch]"
214
+ else
215
+ (s.test_run ? " [test]" : "")
216
+ end
208
217
  flag += " [archived]" if s.archived
209
218
  child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
210
219
  # How full the context was after the last turn: "ctx 12%", blank when unknown.
@@ -222,7 +231,7 @@ module Samagotchi
222
231
  end
223
232
  if project
224
233
  sessions.each { |s| @stdout.puts row.call(s) }
225
- @stdout.puts "#{sessions.empty? ? "" : "\n"}#{scope_note.call(sessions.size)}"
234
+ @stdout.puts "#{"\n" unless sessions.empty?}#{scope_note.call(sessions.size)}"
226
235
  elsif sessions.empty?
227
236
  @stdout.puts "No sessions."
228
237
  else
@@ -276,7 +285,7 @@ module Samagotchi
276
285
  next false
277
286
  rescue Samagotchi::SessionManager::RestartRefused => e
278
287
  @stdout.flush
279
- @stderr.puts e.reason == :not_running || e.reason == :unsupported ? e.message : "session #{id}: #{e.message}"
288
+ @stderr.puts %i[not_running unsupported].include?(e.reason) ? e.message : "session #{id}: #{e.message}"
280
289
  next false
281
290
  rescue ArgumentError => e
282
291
  @stdout.flush
@@ -10,20 +10,41 @@ module Samagotchi
10
10
  #
11
11
  # The user's own lines merged into a running turn are marked kind:
12
12
  # "input": they are part of that turn, not a turn of their own, so a
13
- # reloaded history keeps the turn in one piece.
13
+ # reloaded history keeps the turn in one piece. A line can be a Line
14
+ # naming who sent it (a worker's input from chi send, a delegating parent,
15
+ # a plugin); its input message then carries that source:. The user's own
16
+ # lines carry none, so their saved bytes are as they always were.
14
17
  module Steer
15
18
  KIND = "steer"
16
19
  INPUT_KIND = "input"
17
20
 
21
+ # A drain item for an input line with its sender (source nil = the user).
22
+ # A plain String is still a valid item: the user's line.
23
+ Line = Data.define(:text, :source)
24
+
25
+ # The client ids whose input is not the user's own words. Literals, not
26
+ # SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
27
+ # needs no requires (a spec pins them equal).
28
+ CHI_SEND_CLIENT = "cli:send"
29
+ DELEGATE_CLIENT_PREFIX = "delegate:"
30
+ PLUGIN_CLIENT = "plugin"
31
+
18
32
  # What one drain brought: the merged user text (nil when none) and how
19
- # many lines made it, and the steers in order.
20
- Merge = Struct.new(:content, :count, :steers, keyword_init: true) do
33
+ # many lines made it, the input messages' text grouped by sender
34
+ # (consecutive lines from one sender form one group), and the steers in
35
+ # order.
36
+ Merge = Struct.new(:content, :count, :inputs, :steers, keyword_init: true) do
21
37
  def empty? = content.nil? && steers.empty?
22
38
 
23
- # The messages it appends: the user's first (marked merged input),
24
- # then each steer.
39
+ # The messages it appends: the merged input first (one message per
40
+ # sender run, marked kind input, source: when not the user's), then
41
+ # each steer.
25
42
  def messages
26
- list = content ? [{ role: "user", kind: INPUT_KIND, content: content }] : []
43
+ list = inputs.map do |input|
44
+ message = { role: "user", kind: INPUT_KIND }
45
+ message[:source] = input[:source] if input[:source]
46
+ message.merge(content: input[:content])
47
+ end
27
48
  list + steers.map { |steer| Steer.message(**steer) }
28
49
  end
29
50
 
@@ -36,8 +57,50 @@ module Samagotchi
36
57
  end
37
58
  end
38
59
 
60
+ # The one line the model reads above a steer or merged input (the wire
61
+ # only; the saved content stays raw): who sent it, and that it should
62
+ # follow it or, if it asks for nothing, carry on.
63
+ HEADER_TAIL = "mid-task. Follow it; if it asks for nothing, carry on with the task.]"
64
+ SENDERS = {
65
+ "user" => "from the user",
66
+ "parent_agent" => "from the parent agent that started this session",
67
+ "chi_send" => "sent with chi send",
68
+ "plugin_send" => "sent by a plugin"
69
+ }.freeze
70
+ USER_HEADER = "[Steer #{SENDERS["user"]}, #{HEADER_TAIL}".freeze
71
+
39
72
  module_function
40
73
 
74
+ # The saved source of a worker input line from +client_id+: nil for the
75
+ # user (no client id, the web, an attached TUI, anything unknown).
76
+ def source_for_client(client_id)
77
+ id = client_id.to_s
78
+ if id == CHI_SEND_CLIENT then "chi_send"
79
+ elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
80
+ elsif id == PLUGIN_CLIENT then "plugin_send"
81
+ end
82
+ end
83
+
84
+ # The header for a steer or merged input message, else nil. No source
85
+ # (or "") is the user's; an input with an unknown source too; a steer
86
+ # with an unknown source is a plugin's (its bundle or hook label).
87
+ def header(message)
88
+ kind = (message[:kind] || message["kind"]).to_s
89
+ return unless [KIND, INPUT_KIND].include?(kind)
90
+
91
+ source = (message[:source] || message["source"]).to_s
92
+ source = "user" if source.empty?
93
+ sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
94
+ "[Steer #{sender}, #{HEADER_TAIL}"
95
+ end
96
+
97
+ # The text a steer or merged input goes out as: its header, a newline,
98
+ # then +text+ (the message's content by default). Other messages: +text+.
99
+ def wire_text(message, text = (message[:content] || message["content"]).to_s)
100
+ header = header(message)
101
+ header ? "#{header}\n#{text}" : text
102
+ end
103
+
41
104
  def message(text:, source:)
42
105
  { role: "user", kind: KIND, source: source.to_s, content: text.to_s }
43
106
  end
@@ -46,6 +109,24 @@ module Samagotchi
46
109
  (message[:kind] || message["kind"]).to_s == KIND
47
110
  end
48
111
 
112
+ # The sources of a steer a person sent (a continue card's text, from the
113
+ # user or a parent agent's chi answer), not a plugin's prod.
114
+ PERSON_SOURCES = %w[user parent_agent].freeze
115
+
116
+ # The senders whose message may cut a generation that has only been
117
+ # thinking (Engine#cut_for_steer): the user (no source), chi send, a
118
+ # parent agent. A plugin's never does.
119
+ CUT_SOURCES = ["", "user", "chi_send", "parent_agent"].freeze
120
+
121
+ def cuts?(source)
122
+ CUT_SOURCES.include?(source.to_s)
123
+ end
124
+
125
+ # A steer a person sent: what a recap and a side question keep.
126
+ def person?(message)
127
+ steer?(message) && PERSON_SOURCES.include?((message[:source] || message["source"]).to_s)
128
+ end
129
+
49
130
  # A user message that is a prompt or the user's steering, not a steer:
50
131
  # what "the last prompt" and "user turns" count.
51
132
  def prompt?(message)
@@ -105,8 +186,22 @@ module Samagotchi
105
186
  text = (item[:text] || item["text"]).to_s.strip
106
187
  { source: (item[:source] || item["source"]).to_s, text: text } unless text.empty?
107
188
  end
108
- content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
109
- Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length, steers: steers)
189
+ inputs = input_groups(lines)
190
+ content = inputs.map { |input| input[:content] }.join("\n\n")
191
+ Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length,
192
+ inputs: inputs, steers: steers)
193
+ end
194
+
195
+ # Non-blank input lines as [{content:, source:}], one per run of lines
196
+ # from the same sender.
197
+ def input_groups(lines)
198
+ pairs = lines.filter_map do |line|
199
+ text = (line.is_a?(Line) ? line.text : line).to_s.strip
200
+ [text, line.is_a?(Line) ? line.source : nil] unless text.empty?
201
+ end
202
+ pairs.chunk_while { |a, b| a[1] == b[1] }.map do |run|
203
+ { content: run.map(&:first).join("\n\n"), source: run.first[1] }
204
+ end
110
205
  end
111
206
  end
112
207
  end
@@ -79,9 +79,10 @@ module Samagotchi
79
79
  @built = nil
80
80
  end
81
81
 
82
- # The base prompt (specs, plugins' declarations).
82
+ # The base prompt (specs, plugins' declarations), with Gemma's tool
83
+ # declarations at its end as #build has them.
83
84
  def base(chat: false, thinking: nil)
84
- assist_system_prompt(chat: chat, thinking: thinking)
85
+ with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking), chat: chat)
85
86
  end
86
87
 
87
88
  # Names activated via preloaded --memory entries during prompt
@@ -141,15 +142,18 @@ module Samagotchi
141
142
  def assist_system_prompt(chat: false, thinking: nil)
142
143
  return chat_system_prompt if chat
143
144
 
144
- declarations = tool_declarations
145
145
  hint = tool_call_hint
146
146
  turn_preamble = turn_preamble_instruction(thinking)
147
+ # Gemma 4's template declares the tools at the end of the system turn
148
+ # (#system_prompt_with_index puts them there).
149
+ tools = if gemma_tail_declarations?
150
+ "Your tools are declared at the end of this system prompt."
151
+ else
152
+ "You have access to the following tools:\n\n#{tool_declarations}\n"
153
+ end
147
154
 
148
155
  <<~SYS
149
- You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. You have access to the following tools:
150
-
151
- #{declarations}
152
-
156
+ You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. #{tools}
153
157
  #{hint}
154
158
  You may make multiple tool calls. After seeing tool results, continue reasoning or answer the user.
155
159
  #{turn_preamble}
@@ -234,9 +238,16 @@ module Samagotchi
234
238
  "Project memories:\n#{project_index}",
235
239
  "System memories:\n#{system_index}"
236
240
  ].join("\n\n")
237
- [thinking_token + base, rg_guidance, project_description, project_location, current_model, current_session, memory_sections, system_identity_section, explicit_memory_section].compact.join("\n")
241
+ prompt = [thinking_token + base, rg_guidance, project_description, project_location, current_model, current_session, memory_sections, system_identity_section, explicit_memory_section].compact.join("\n")
242
+ with_tail_declarations(prompt, chat: chat)
238
243
  end
239
244
 
245
+ def with_tail_declarations(prompt, chat:)
246
+ chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations
247
+ end
248
+
249
+ def gemma_tail_declarations? = profile.name == "gemma4"
250
+
240
251
  # B-light: auto-preload the built-in identity memory.
241
252
  # The file is installed by SystemBundle.ensure! as a normal system memory,
242
253
  # but its body is injected here so the agent has it without an extra tool call.
@@ -46,10 +46,10 @@ module Samagotchi
46
46
  first_command = model && (attach || resume) ? "/model #{model}" : nil
47
47
  surface = open_surface
48
48
  attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
49
- first_command: first_command, no_interrupt: no_interrupt,
50
- default_input: default_input && !prompt && !attach && !resume,
51
- wait_at_eof: !$stdin.tty?,
52
- parent_answers: Guardrails::ParentApprovals.parent_process?)
49
+ first_command: first_command, no_interrupt: no_interrupt,
50
+ default_input: default_input && !prompt && !attach && !resume,
51
+ wait_at_eof: !$stdin.tty?,
52
+ parent_answers: Guardrails::ParentApprovals.parent_process?)
53
53
  ended = begin
54
54
  attached.run
55
55
  ensure
@@ -144,7 +144,7 @@ module Samagotchi
144
144
  return if flags.empty?
145
145
 
146
146
  verb = flags.size == 1 ? "applies" : "apply"
147
- err.puts "(#{flags.join(' and ')} #{verb} to a new session; #{session_id}'s prompt is already built)"
147
+ err.puts "(#{flags.join(" and ")} #{verb} to a new session; #{session_id}'s prompt is already built)"
148
148
  end
149
149
 
150
150
  # @return [BridgeClient, nil] the running worker's, or nil when none runs
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
3
  require_relative "event_renderer"
5
4
  require_relative "formatting"
6
5
  require_relative "../prompt_history"
@@ -1174,7 +1173,8 @@ module Samagotchi
1174
1173
  Bridge::TurnAccumulator.replay_events(turn).each do |event|
1175
1174
  case event[:type]
1176
1175
  # A new step starts its line over, as live (a join during a hold).
1177
- when :generation_started then tail, lane = nil, :writing
1176
+ when :generation_started then tail = nil
1177
+ lane = :writing
1178
1178
  when :generation_chunk
1179
1179
  tail, lane = event[:thinking].to_s.empty? ? [event[:text], :writing] : [event[:thinking], :thinking]
1180
1180
  when :tool_call_started
@@ -1189,7 +1189,7 @@ module Samagotchi
1189
1189
  # hook's notice, a retry's line), as they were drawn live.
1190
1190
  # Questions are left out: the pending one is asked after the
1191
1191
  # replay, answered ones showed no line.
1192
- when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry then @renderer.call(event)
1192
+ when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut then @renderer.call(event)
1193
1193
  end
1194
1194
  end
1195
1195
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
3
  require_relative "formatting"
5
4
 
6
5
  module Samagotchi
@@ -80,6 +79,9 @@ module Samagotchi
80
79
  when :empty_answer_retry
81
80
  @view.finish_thinking_spinner
82
81
  @view.print_line(@view.format_empty_retry_line(event))
82
+ when :steer_cut
83
+ @view.finish_thinking_spinner
84
+ @view.print_line(@view.format_steer_cut_line(event))
83
85
  when :turn_completed
84
86
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
85
87
  when :turn_canceled
@@ -188,7 +190,7 @@ module Samagotchi
188
190
  Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
189
191
  end
190
192
 
191
- private
193
+ private
192
194
 
193
195
  def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
194
196
  return if activity.nil?