samagotchi 0.21.0 → 0.23.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/bin/chi +9 -4
  4. data/docs/architecture.md +3 -2
  5. data/docs/configuration.md +15 -4
  6. data/docs/releasing.md +1 -1
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +11 -5
  9. data/docs/testing.md +6 -0
  10. data/lib/samagotchi/answer_command.rb +11 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  12. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  13. data/lib/samagotchi/bootstrap_command.rb +2 -2
  14. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  17. data/lib/samagotchi/bridge.rb +12 -9
  18. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  19. data/lib/samagotchi/bridge_client.rb +1 -1
  20. data/lib/samagotchi/bundle_command.rb +39 -29
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  23. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  25. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  26. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  29. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  31. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/cancellation_controller.rb +1 -1
  38. data/lib/samagotchi/cli/command.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  40. data/lib/samagotchi/client.rb +3 -5
  41. data/lib/samagotchi/commands/registry.rb +2 -2
  42. data/lib/samagotchi/config.rb +38 -14
  43. data/lib/samagotchi/context_note.rb +1 -1
  44. data/lib/samagotchi/context_status.rb +6 -6
  45. data/lib/samagotchi/continue_offer.rb +44 -12
  46. data/lib/samagotchi/debug_log.rb +1 -1
  47. data/lib/samagotchi/desktop/macos.rb +5 -4
  48. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  49. data/lib/samagotchi/engine.rb +49 -19
  50. data/lib/samagotchi/gem_update.rb +2 -1
  51. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  52. data/lib/samagotchi/guardrails/approval.rb +5 -5
  53. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  54. data/lib/samagotchi/guardrails/gate.rb +2 -2
  55. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  56. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  57. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  58. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  59. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  60. data/lib/samagotchi/hooks/loader.rb +18 -16
  61. data/lib/samagotchi/hooks/registry.rb +10 -2
  62. data/lib/samagotchi/host_registry.rb +1 -3
  63. data/lib/samagotchi/idle_recap.rb +18 -15
  64. data/lib/samagotchi/idle_reminders.rb +6 -3
  65. data/lib/samagotchi/image_store.rb +5 -2
  66. data/lib/samagotchi/kernel_loop.rb +31 -31
  67. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  68. data/lib/samagotchi/llm/errors.rb +33 -2
  69. data/lib/samagotchi/llm/http.rb +12 -12
  70. data/lib/samagotchi/llm/openai_chat.rb +53 -30
  71. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  72. data/lib/samagotchi/log.rb +2 -2
  73. data/lib/samagotchi/log_subscriber.rb +2 -1
  74. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  75. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  76. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  77. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  78. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  79. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  80. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  81. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  82. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  83. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  84. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  85. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  86. data/lib/samagotchi/memory_bundle.rb +1 -0
  87. data/lib/samagotchi/model_overlay.rb +3 -5
  88. data/lib/samagotchi/model_profile.rb +12 -8
  89. data/lib/samagotchi/note_command.rb +2 -1
  90. data/lib/samagotchi/output_formatter.rb +18 -17
  91. data/lib/samagotchi/owner_lock.rb +2 -1
  92. data/lib/samagotchi/parent_report.rb +24 -7
  93. data/lib/samagotchi/plugin/api.rb +8 -5
  94. data/lib/samagotchi/plugin/context.rb +1 -3
  95. data/lib/samagotchi/plugin/loader.rb +1 -1
  96. data/lib/samagotchi/plugin/service.rb +1 -1
  97. data/lib/samagotchi/plugin/sessions.rb +1 -0
  98. data/lib/samagotchi/plugin_tasks.rb +1 -1
  99. data/lib/samagotchi/prompt.rb +68 -12
  100. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  101. data/lib/samagotchi/question_desk.rb +7 -13
  102. data/lib/samagotchi/recap_setup.rb +1 -1
  103. data/lib/samagotchi/relay_desk.rb +1 -1
  104. data/lib/samagotchi/reminder_store.rb +2 -1
  105. data/lib/samagotchi/reply_wait.rb +12 -2
  106. data/lib/samagotchi/send_command.rb +4 -2
  107. data/lib/samagotchi/session.rb +2 -2
  108. data/lib/samagotchi/session_archive_command.rb +5 -1
  109. data/lib/samagotchi/session_commands.rb +1 -3
  110. data/lib/samagotchi/session_manager.rb +2 -4
  111. data/lib/samagotchi/session_metrics.rb +10 -10
  112. data/lib/samagotchi/session_observer.rb +3 -2
  113. data/lib/samagotchi/session_retention.rb +2 -2
  114. data/lib/samagotchi/sessions_command.rb +14 -5
  115. data/lib/samagotchi/system_prompt.rb +19 -8
  116. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  117. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  118. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  119. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  120. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  121. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  122. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  123. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  124. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  125. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  126. data/lib/samagotchi/terminal_ui.rb +9 -5
  127. data/lib/samagotchi/text_diff.rb +12 -12
  128. data/lib/samagotchi/thinking.rb +6 -2
  129. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  130. data/lib/samagotchi/tool_activity.rb +1 -0
  131. data/lib/samagotchi/tool_call_parser.rb +13 -14
  132. data/lib/samagotchi/tool_declarations.rb +36 -35
  133. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  134. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  135. data/lib/samagotchi/tools/delegate.rb +1 -1
  136. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  137. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  138. data/lib/samagotchi/tools/edit.rb +14 -13
  139. data/lib/samagotchi/tools/execute.rb +3 -5
  140. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  141. data/lib/samagotchi/tools/memory.rb +21 -22
  142. data/lib/samagotchi/tools/read.rb +7 -7
  143. data/lib/samagotchi/tools/task_create.rb +1 -1
  144. data/lib/samagotchi/tools/task_get.rb +1 -1
  145. data/lib/samagotchi/tools/task_list.rb +1 -1
  146. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  147. data/lib/samagotchi/tools/task_stop.rb +1 -1
  148. data/lib/samagotchi/tools/task_wait.rb +3 -4
  149. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  150. data/lib/samagotchi/tools/write.rb +1 -1
  151. data/lib/samagotchi/turn_flow.rb +8 -8
  152. data/lib/samagotchi/turn_note.rb +5 -1
  153. data/lib/samagotchi/turn_state.rb +22 -0
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/vision_support.rb +2 -2
  156. data/lib/samagotchi/web/app.rb +13 -6
  157. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  158. data/lib/samagotchi/web/qr.rb +7 -1
  159. data/lib/samagotchi/web/server.rb +6 -2
  160. data/lib/samagotchi/worker.rb +10 -2
  161. metadata +21 -20
@@ -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
@@ -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
@@ -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
@@ -188,7 +187,7 @@ module Samagotchi
188
187
  Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
189
188
  end
190
189
 
191
- private
190
+ private
192
191
 
193
192
  def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
194
193
  return if activity.nil?
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
16
16
  status = activity[:status].to_s
17
17
  elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
18
- "#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
18
+ "#{paint("tool>", 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
19
19
  end
20
20
 
21
21
  # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
@@ -75,7 +75,7 @@ module Samagotchi
75
75
  return "" unless diff.is_a?(Hash)
76
76
 
77
77
  get = ->(key) { diff[key] || diff[key.to_s] }
78
- " #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
78
+ " #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
79
79
  end
80
80
 
81
81
  # Prompt labels by the sender's client_id prefix (turn_events.js
@@ -113,7 +113,7 @@ module Samagotchi
113
113
  params = part[:params].to_s.strip
114
114
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
115
115
  status = part[:status].to_s
116
- "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
116
+ "#{paint("tool>", 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
117
117
  "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
118
118
  end
119
119
 
@@ -233,8 +233,8 @@ module Samagotchi
233
233
  value = tps.to_f
234
234
  return "" unless value.positive?
235
235
 
236
- number = value >= 1000 ? "#{format('%.1f', (value / 100).round / 10.0)}k" : value.round.to_s
237
- "#{'~' if source.to_s == 'estimate'}#{number} tok/s"
236
+ number = value >= 1000 ? "#{format("%.1f", (value / 100).round / 10.0)}k" : value.round.to_s
237
+ "#{"~" if source.to_s == "estimate"}#{number} tok/s"
238
238
  end
239
239
 
240
240
  # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
@@ -297,7 +297,7 @@ module Samagotchi
297
297
  def status_rows(segments, width)
298
298
  return [] if segments.empty? || width <= 0
299
299
 
300
- row = cap_preview_text("status> #{segments.join(' | ')}", width)
300
+ row = cap_preview_text("status> #{segments.join(" | ")}", width)
301
301
  [color_output? ? paint(row, 90) : row]
302
302
  end
303
303
 
@@ -336,7 +336,7 @@ module Samagotchi
336
336
 
337
337
  visible = names.first(limit)
338
338
  suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
339
- "#{label}: #{visible.join(', ')}#{suffix}"
339
+ "#{label}: #{visible.join(", ")}#{suffix}"
340
340
  end
341
341
 
342
342
  def cap_preview_text(text, width)
@@ -411,8 +411,8 @@ module Samagotchi
411
411
  return nil unless last
412
412
 
413
413
  source = tokens[:tps_source].to_s
414
- text = +"#{speed_text(last, source)} out"
415
- text << ", #{speed_text(tokens[:last_prefill_tps], 'server')} prompt" if tokens[:last_prefill_tps]
414
+ text = "#{speed_text(last, source)} out"
415
+ text << ", #{speed_text(tokens[:last_prefill_tps], "server")} prompt" if tokens[:last_prefill_tps]
416
416
  text << " (last, #{source})"
417
417
  text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
418
418
  text
@@ -428,7 +428,7 @@ module Samagotchi
428
428
  return "#{seconds.round}s" if seconds < 60
429
429
 
430
430
  total_seconds = seconds.round
431
- "#{total_seconds / 60}m #{format('%02d', total_seconds % 60)}s"
431
+ "#{total_seconds / 60}m #{format("%02d", total_seconds % 60)}s"
432
432
  end
433
433
 
434
434
  def paint(text, code)
@@ -98,8 +98,8 @@ module Samagotchi
98
98
 
99
99
  begin
100
100
  buf = Reline.line_buffer.to_s
101
- unless buf.empty?
102
- return [] unless buf.lstrip.start_with?("/")
101
+ if !buf.empty? && !buf.lstrip.start_with?("/")
102
+ return []
103
103
  end
104
104
  rescue StandardError
105
105
  nil
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  end
40
40
 
41
41
  def alive? = @thread&.alive? || false
42
-
42
+
43
43
  # Drop the open read and start it again with the current prompt.
44
44
  # @param keep_text [Boolean] with what is typed there now
45
45
  # @param prefill [String, nil] with this text instead
@@ -48,7 +48,7 @@ module Samagotchi
48
48
  @prefill = text unless text.to_s.empty?
49
49
  @thread&.raise(Reprompt)
50
50
  end
51
-
51
+
52
52
  # Type +text+ into the next read (from the sink, between two reads).
53
53
  def prefill_next(text)
54
54
  @prefill = text
@@ -57,7 +57,7 @@ module Samagotchi
57
57
  # @return [String, nil] what the open read holds
58
58
  def typed_text
59
59
  return nil unless $stdin.tty?
60
-
60
+
61
61
  Reline.line_buffer.to_s
62
62
  rescue StandardError
63
63
  nil
@@ -33,29 +33,29 @@ module Samagotchi
33
33
  # @param pending [Hash] Engine#pending_question (symbol or string keys)
34
34
  def initialize(pending)
35
35
  field = ->(key) { pending[key] || pending[key.to_s] }
36
- approval = field.(:approval)
36
+ approval = field.call(:approval)
37
37
  @preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
38
- @id = field.(:id).to_s
39
- @question = field.(:question).to_s
38
+ @id = field.call(:id).to_s
39
+ @question = field.call(:question).to_s
40
40
  # A delegate's approval relayed here: the delegate's own text is the
41
41
  # question, who asks a dim line (#note).
42
- relay = field.(:relay)
42
+ relay = field.call(:relay)
43
43
  if relay.is_a?(Hash)
44
44
  get = ->(key) { relay[key] || relay[key.to_s] }
45
- chain = Array(get.(:chain)).map(&:to_s)
46
- chain = [get.(:child_id).to_s[0, 8]] if chain.empty?
45
+ chain = Array(get.call(:chain)).map(&:to_s)
46
+ chain = [get.call(:child_id).to_s[0, 8]] if chain.empty?
47
47
  @delegate = chain.reverse.join(" → ")
48
- @delegate_task = get.(:task).to_s
49
- @question = get.(:asked).to_s unless get.(:asked).to_s.strip.empty?
48
+ @delegate_task = get.call(:task).to_s
49
+ @question = get.call(:asked).to_s unless get.call(:asked).to_s.strip.empty?
50
50
  end
51
- self.relayed_to = field.(:relayed_to)
52
- @options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
53
- header = field.(:header).to_s.strip
51
+ self.relayed_to = field.call(:relayed_to)
52
+ @options = Array(field.call(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
53
+ header = field.call(:header).to_s.strip
54
54
  @header = header.empty? ? nil : header
55
- @multi = !!field.(:multi_select)
56
- @free = !!field.(:allow_freeform)
57
- @approval = field.(:kind).to_s == "approval"
58
- @continue = field.(:kind).to_s == "continue"
55
+ @multi = !!field.call(:multi_select)
56
+ @free = !!field.call(:allow_freeform)
57
+ @approval = field.call(:kind).to_s == "approval"
58
+ @continue = field.call(:kind).to_s == "continue"
59
59
  end
60
60
 
61
61
  def multi? = @multi
@@ -121,19 +121,19 @@ module Samagotchi
121
121
  return [] unless preview.is_a?(Hash)
122
122
 
123
123
  get = ->(key) { preview[key] || preview[key.to_s] }
124
- return [paint.("this edit would fail: #{get.(:error)}", 33)] if get.(:error)
125
- return [paint.("diff not shown: #{get.(:skipped)}", 2)] if get.(:skipped)
124
+ return [paint.call("this edit would fail: #{get.call(:error)}", 33)] if get.call(:error)
125
+ return [paint.call("diff not shown: #{get.call(:skipped)}", 2)] if get.call(:skipped)
126
126
 
127
- lines = get.(:text).to_s.split("\n")
127
+ lines = get.call(:text).to_s.split("\n")
128
128
  return [] if lines.empty?
129
129
 
130
130
  # TextDiff's own cut note ("… N more lines") counts toward the rest.
131
131
  cut = lines.last.to_s[/\A… (\d+) more lines\z/, 1]
132
132
  lines.pop if cut
133
133
  hidden = [lines.size - PREVIEW_LINES, 0].max + cut.to_i
134
- shown = lines.first(PREVIEW_LINES).map { |line| paint.(line, diff_code(line)) }
135
- shown.unshift(paint.("new file", 2)) if get.(:new_file)
136
- shown << paint.("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
134
+ shown = lines.first(PREVIEW_LINES).map { |line| paint.call(line, diff_code(line)) }
135
+ shown.unshift(paint.call("new file", 2)) if get.call(:new_file)
136
+ shown << paint.call("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
137
137
  shown
138
138
  end
139
139
 
@@ -142,7 +142,7 @@ module Samagotchi
142
142
  # @param outcome [String] the answer (#answer_text) or what closed it
143
143
  def summary(outcome, paint: ->(text, _code) { text })
144
144
  who = @delegate ? "#{@delegate}: " : ""
145
- "#{paint.("#{mark}#{who}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
145
+ "#{paint.call("#{mark}#{who}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
146
146
  end
147
147
 
148
148
  # @param answer [Answer] an accepted one
@@ -166,7 +166,7 @@ module Samagotchi
166
166
  raw = raw.to_s.strip
167
167
  # "1,3; my text": the first ';' separates the selection from freeform text.
168
168
  sel_part, free_part = raw.include?(";") ? raw.split(";", 2).map(&:strip) : [raw, nil]
169
- free_part = nil if free_part&.empty?
169
+ free_part = nil if free_part && free_part.empty?
170
170
  # Models forget to flag freeform: accept it anyway, with a note.
171
171
  note = free_part && !free? ? "(note: freeform not flagged but accepting '#{free_part}')" : nil
172
172
 
@@ -31,7 +31,7 @@ module Samagotchi
31
31
 
32
32
  # The line a continue offer leaves in the scrollback.
33
33
  def self.continue_summary(outcome, paint: ->(text, _code) { text })
34
- "#{paint.("? #{CONTINUE_QUESTION}", 33)} → #{outcome}"
34
+ "#{paint.call("? #{CONTINUE_QUESTION}", 33)} → #{outcome}"
35
35
  end
36
36
 
37
37
  # @param question [String] the first row's text, wrapped when there is room
@@ -77,14 +77,14 @@ module Samagotchi
77
77
 
78
78
  def layout(width, hint:, details:, header:, folded:)
79
79
  rows = []
80
- rows << @paint.(@header, 1) if header && @header
81
- rows << @paint.(@note, 2) if header && @note
80
+ rows << @paint.call(@header, 1) if header && @header
81
+ rows << @paint.call(@note, 2) if header && @note
82
82
  question_rows = wrap(@question, width, first: @mark, indent: " " * @mark.size)
83
83
  question_rows = question_rows.first(1) unless details
84
- rows.concat(question_rows.map { |row| @paint.(row, @question_code) })
85
- rows.concat(@details.map { |row| @paint.(row, @question_code) }) if details
86
- rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.(" #{o.text}", 92) })
87
- rows << @paint.(" [#{@hint}]", 90) if hint && @hint
84
+ rows.concat(question_rows.map { |row| @paint.call(row, @question_code) })
85
+ rows.concat(@details.map { |row| @paint.call(row, @question_code) }) if details
86
+ rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}", 92) })
87
+ rows << @paint.call(" [#{@hint}]", 90) if hint && @hint
88
88
  rows
89
89
  end
90
90
 
@@ -97,13 +97,13 @@ module Samagotchi
97
97
  candidate = line.empty? ? " #{item}" : "#{line} · #{item}"
98
98
  if !line.empty? && display_width(candidate) > width
99
99
  rows << line
100
- line = +" #{item}"
100
+ line = " #{item}"
101
101
  else
102
102
  line = +candidate
103
103
  end
104
104
  end
105
105
  rows << line unless line.empty?
106
- rows.map { |row| @paint.(row, 92) }
106
+ rows.map { |row| @paint.call(row, 92) }
107
107
  end
108
108
 
109
109
  # Word wrap at +width+ columns, the first row after +first+, the others
@@ -122,7 +122,7 @@ module Samagotchi
122
122
  while display_width(line.rstrip) > width
123
123
  head, = Reline::Unicode.take_mbchar_range(line, 0, width, padding: false)
124
124
  rows << head
125
- line = +"#{indent}#{line[head.size..]}"
125
+ line = "#{indent}#{line[head.size..]}"
126
126
  end
127
127
  end
128
128
  rows << line.rstrip unless line.strip.empty? && !rows.empty?
@@ -216,7 +216,7 @@ module Samagotchi
216
216
  # The prompt and the text as render_finished writes them: one line per
217
217
  # input line, with a trailing space when a line fills the width exactly.
218
218
  def seam_final_lines
219
- @buffer_of_lines.size.times.map do |i|
219
+ Array.new(@buffer_of_lines.size) do |i|
220
220
  line = Reline::Unicode.strip_non_printing_start_end(prompt_list[i]) + modified_lines[i]
221
221
  split_line_by_width(line, screen_width).last.empty? ? "#{line} " : line
222
222
  end
@@ -43,6 +43,7 @@ module Samagotchi
43
43
  def <<(item)
44
44
  @lock.synchronize do
45
45
  next @answers << item if @answers
46
+
46
47
  taken = @turn && item.first == :line && @turn.call(item.last)
47
48
  # Not now: the line goes back into the prompt.
48
49
  next @reader&.prefill_next(item.last) if taken == :back
@@ -130,8 +131,8 @@ module Samagotchi
130
131
  end
131
132
  end
132
133
 
133
- def with_surface_lock(&block)
134
- @surface.respond_to?(:synchronize) ? @surface.synchronize(&block) : yield
134
+ def with_surface_lock(&)
135
+ @surface.respond_to?(:synchronize) ? @surface.synchronize(&) : yield
135
136
  end
136
137
  end
137
138
  end