samagotchi 0.22.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/bin/chi +9 -4
  4. data/docs/configuration.md +4 -4
  5. data/docs/releasing.md +1 -1
  6. data/docs/testing.md +6 -0
  7. data/lib/samagotchi/answer_command.rb +4 -2
  8. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +2 -2
  11. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  12. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  14. data/lib/samagotchi/bridge.rb +12 -9
  15. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  16. data/lib/samagotchi/bridge_client.rb +1 -1
  17. data/lib/samagotchi/bundle_command.rb +39 -29
  18. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  22. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  23. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  28. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  30. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  31. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  34. data/lib/samagotchi/cancellation_controller.rb +1 -1
  35. data/lib/samagotchi/cli/command.rb +1 -1
  36. data/lib/samagotchi/client.rb +3 -5
  37. data/lib/samagotchi/commands/registry.rb +2 -2
  38. data/lib/samagotchi/config.rb +38 -14
  39. data/lib/samagotchi/context_note.rb +1 -1
  40. data/lib/samagotchi/context_status.rb +6 -6
  41. data/lib/samagotchi/continue_offer.rb +15 -8
  42. data/lib/samagotchi/debug_log.rb +1 -1
  43. data/lib/samagotchi/desktop/macos.rb +5 -4
  44. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  45. data/lib/samagotchi/engine.rb +17 -17
  46. data/lib/samagotchi/gem_update.rb +2 -1
  47. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  48. data/lib/samagotchi/guardrails/approval.rb +5 -5
  49. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  50. data/lib/samagotchi/guardrails/gate.rb +2 -2
  51. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  52. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  53. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  54. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  55. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  56. data/lib/samagotchi/hooks/loader.rb +18 -16
  57. data/lib/samagotchi/hooks/registry.rb +10 -2
  58. data/lib/samagotchi/host_registry.rb +1 -3
  59. data/lib/samagotchi/idle_recap.rb +18 -15
  60. data/lib/samagotchi/idle_reminders.rb +6 -3
  61. data/lib/samagotchi/image_store.rb +5 -2
  62. data/lib/samagotchi/kernel_loop.rb +31 -31
  63. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  64. data/lib/samagotchi/llm/errors.rb +2 -2
  65. data/lib/samagotchi/llm/http.rb +12 -12
  66. data/lib/samagotchi/llm/openai_chat.rb +39 -28
  67. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  68. data/lib/samagotchi/log.rb +2 -2
  69. data/lib/samagotchi/log_subscriber.rb +2 -1
  70. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  71. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  72. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  73. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  74. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  75. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  76. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  77. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  78. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  79. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  80. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  81. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  82. data/lib/samagotchi/memory_bundle.rb +1 -0
  83. data/lib/samagotchi/model_overlay.rb +3 -5
  84. data/lib/samagotchi/model_profile.rb +12 -8
  85. data/lib/samagotchi/note_command.rb +2 -1
  86. data/lib/samagotchi/output_formatter.rb +18 -17
  87. data/lib/samagotchi/owner_lock.rb +2 -1
  88. data/lib/samagotchi/plugin/api.rb +8 -5
  89. data/lib/samagotchi/plugin/context.rb +1 -3
  90. data/lib/samagotchi/plugin/loader.rb +1 -1
  91. data/lib/samagotchi/plugin/service.rb +1 -1
  92. data/lib/samagotchi/plugin/sessions.rb +1 -0
  93. data/lib/samagotchi/plugin_tasks.rb +1 -1
  94. data/lib/samagotchi/prompt.rb +68 -12
  95. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  96. data/lib/samagotchi/question_desk.rb +7 -7
  97. data/lib/samagotchi/recap_setup.rb +1 -1
  98. data/lib/samagotchi/relay_desk.rb +1 -1
  99. data/lib/samagotchi/reminder_store.rb +2 -1
  100. data/lib/samagotchi/send_command.rb +4 -2
  101. data/lib/samagotchi/session.rb +2 -2
  102. data/lib/samagotchi/session_archive_command.rb +5 -1
  103. data/lib/samagotchi/session_commands.rb +1 -3
  104. data/lib/samagotchi/session_manager.rb +2 -4
  105. data/lib/samagotchi/session_metrics.rb +10 -10
  106. data/lib/samagotchi/session_observer.rb +3 -2
  107. data/lib/samagotchi/session_retention.rb +2 -2
  108. data/lib/samagotchi/sessions_command.rb +14 -5
  109. data/lib/samagotchi/system_prompt.rb +19 -8
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  111. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  112. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  113. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  114. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  115. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  116. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  117. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  118. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  119. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  120. data/lib/samagotchi/terminal_ui.rb +9 -5
  121. data/lib/samagotchi/text_diff.rb +12 -12
  122. data/lib/samagotchi/thinking.rb +6 -2
  123. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  124. data/lib/samagotchi/tool_activity.rb +1 -0
  125. data/lib/samagotchi/tool_call_parser.rb +13 -14
  126. data/lib/samagotchi/tool_declarations.rb +36 -35
  127. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  128. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  129. data/lib/samagotchi/tools/delegate.rb +1 -1
  130. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  131. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  132. data/lib/samagotchi/tools/edit.rb +14 -13
  133. data/lib/samagotchi/tools/execute.rb +3 -5
  134. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  135. data/lib/samagotchi/tools/memory.rb +21 -22
  136. data/lib/samagotchi/tools/read.rb +7 -7
  137. data/lib/samagotchi/tools/task_create.rb +1 -1
  138. data/lib/samagotchi/tools/task_get.rb +1 -1
  139. data/lib/samagotchi/tools/task_list.rb +1 -1
  140. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  141. data/lib/samagotchi/tools/task_stop.rb +1 -1
  142. data/lib/samagotchi/tools/task_wait.rb +3 -4
  143. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  144. data/lib/samagotchi/tools/write.rb +1 -1
  145. data/lib/samagotchi/turn_flow.rb +8 -8
  146. data/lib/samagotchi/turn_note.rb +5 -1
  147. data/lib/samagotchi/version.rb +1 -1
  148. data/lib/samagotchi/vision_support.rb +2 -2
  149. data/lib/samagotchi/web/app.rb +13 -6
  150. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  151. data/lib/samagotchi/web/qr.rb +7 -1
  152. data/lib/samagotchi/web/server.rb +6 -2
  153. data/lib/samagotchi/worker.rb +10 -2
  154. metadata +20 -20
@@ -73,7 +73,7 @@ module Samagotchi
73
73
  true
74
74
  rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
75
75
  api&.abort!
76
- raise if e.is_a?(Interrupt)
76
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
77
77
 
78
78
  failed(bundle_name, basename, "#{e.class}: #{e.message}", failures)
79
79
  end
@@ -62,7 +62,7 @@ module Samagotchi
62
62
  def running? = @state == :running
63
63
 
64
64
  # @return [Symbol] :idle, :running or :stopped
65
- def state = @state
65
+ attr_reader :state
66
66
 
67
67
  # Run the stop callbacks (newest first) once; a raise is logged, the
68
68
  # rest still run. It never starts again after this.
@@ -38,6 +38,7 @@ module Samagotchi
38
38
  def fork(messages:, title: nil, prompt: nil)
39
39
  parent_id = @host.session_id.call or raise Error, "this session has no id yet"
40
40
  raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
41
+
41
42
  state_dir = self.state_dir
42
43
  prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
43
44
  check_children(parent_id, state_dir) if prompt
@@ -121,7 +121,7 @@ module Samagotchi
121
121
  sleep(INIT_WAIT_POLL)
122
122
  end
123
123
  Log.info(:plugins, "init_wait", ms: ((@clock.call - started) * 1000).round,
124
- cancelled: controller&.cancelled? ? true : nil)
124
+ cancelled: controller&.cancelled? || nil)
125
125
  true
126
126
  end
127
127
 
@@ -3,6 +3,7 @@
3
3
  require_relative "model_profile"
4
4
  require_relative "prompt_literal_guard"
5
5
  require_relative "vision_context"
6
+ require_relative "tool_response"
6
7
 
7
8
  module Samagotchi
8
9
  # Formats a message list into a prompt string ready for the /completion endpoint.
@@ -10,10 +11,13 @@ module Samagotchi
10
11
  # Supports multiple model profiles (Gemma 4, Qwen 3.6, etc.) with different
11
12
  # token formats, role prefixing strategies, and tool response handling.
12
13
  #
13
- # Gemma 4:
14
- # - Turn markers: <|turn>ROLE\n...<end_of_turn>\n
15
- # - Tool response: standalone <|tool_response>...<tool_response|>
16
- # - Generation cue: <|turn>model\n
14
+ # Gemma 4 (as its chat template renders a conversation):
15
+ # - Turn markers: <|turn>ROLE\n...<turn|>\n, the text trimmed
16
+ # - Tool calls and their responses stay inside the model's turn:
17
+ # ...<tool_call|><|tool_response>response:NAME{value:<|"|>...<|"|>}<tool_response|>
18
+ # and the model goes on in the same turn
19
+ # - Generation cue: <|turn>model\n, or nothing after a tool response
20
+ # - An answer's thought is dropped once a later user turn starts
17
21
  #
18
22
  # Qwen 3.6:
19
23
  # - Role framing: <|im_start|>ROLE\n...<|im_end|>
@@ -51,11 +55,9 @@ module Samagotchi
51
55
  else
52
56
  format_with_turn_markers(messages, profile, suffixes)
53
57
  end
54
- [text + prefill.to_s, images]
58
+ [text + prefill_for(messages, profile, prefill), images]
55
59
  end
56
60
 
57
- private
58
-
59
61
  def self.image_lines(items, profile, images)
60
62
  return "" if items.empty?
61
63
 
@@ -71,19 +73,70 @@ module Samagotchi
71
73
  end
72
74
 
73
75
  def self.format_with_turn_markers(messages, profile, suffixes)
74
- # Gemma 4 style: <|turn>ROLE\n...<end_of_turn>\n
76
+ last_user = messages.rindex { |m| m[:role] == "user" } || -1
75
77
  parts = messages.each_with_index.map do |m, index|
76
78
  content = prompt_content_for(m, profile) + suffixes[index]
77
- if m[:role] == "tool_response"
78
- "#{profile.tool_response_open}\n#{content}#{profile.tool_response_close}\n"
79
+ previous = index.positive? ? messages[index - 1][:role] : nil
80
+ following = messages[index + 1]&.dig(:role)
81
+ case m[:role]
82
+ when "tool_response"
83
+ # Inside the model's turn; closed when another role follows.
84
+ tool_response_blocks(content, profile) +
85
+ (following && following != "tool_response" && following != "model" ? "#{profile.turn_end}\n" : "")
86
+ when "model"
87
+ content = strip_gemma_thought(content) if index < last_user && !content.include?(profile.tool_call_open)
88
+ opener = previous == "tool_response" ? "" : "#{profile.turn_start}model\n"
89
+ closer = following == "tool_response" ? "" : "#{profile.turn_end}\n"
90
+ "#{opener}#{content.strip}#{closer}"
79
91
  else
80
- "#{profile.turn_start}#{m[:role]}\n#{content}#{profile.turn_end}\n"
92
+ "#{profile.turn_start}#{m[:role]}\n#{content.strip}#{profile.turn_end}\n"
81
93
  end
82
94
  end
83
- parts << "#{profile.turn_start}model\n"
95
+ parts << "#{profile.turn_start}model\n" unless ends_in_tool_response?(messages)
84
96
  parts.join
85
97
  end
86
98
 
99
+ def self.ends_in_tool_response?(messages)
100
+ messages.last&.dig(:role) == "tool_response"
101
+ end
102
+
103
+ # The text after the generation cue: +prefill+, except where Gemma's
104
+ # turn goes on after a tool response (no cue, so nothing to fill).
105
+ # The prompt and the model message the kernel keeps both use it.
106
+ def self.prefill_for(messages, profile, prefill)
107
+ return "" if !profile.uses_role_prefixes? && ends_in_tool_response?(messages)
108
+
109
+ prefill.to_s
110
+ end
111
+
112
+ # A native tool_response entry (ToolResponse.joined: "[name]" outputs
113
+ # joined by its SEPARATOR) as Gemma's template writes the results: one
114
+ # response:NAME{value:<|"|>…<|"|>} block per call. A part that doesn't
115
+ # open with a "[name]" is the previous output's own text.
116
+ def self.tool_response_blocks(content, profile)
117
+ runs = content.split(ToolResponse::SEPARATOR).each_with_object([]) do |part, acc|
118
+ if (match = part.match(/\A\[([^\]\s]+)\](?:\n| |\z)/))
119
+ acc << [match[1], match.post_match]
120
+ elsif acc.empty?
121
+ acc << ["unknown", part]
122
+ else
123
+ acc.last[1] = "#{acc.last[1]}#{ToolResponse::SEPARATOR}#{part}"
124
+ end
125
+ end
126
+ runs = [["unknown", ""]] if runs.empty?
127
+ q = profile.string_delim
128
+ runs.map do |name, body|
129
+ "#{profile.tool_response_open}response:#{name}{value:#{q}#{body}#{q}}#{profile.tool_response_close}"
130
+ end.join
131
+ end
132
+
133
+ GEMMA_THOUGHT_BLOCK = /#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN.delete_suffix("thought"))}.*?#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}/m
134
+ private_constant :GEMMA_THOUGHT_BLOCK
135
+
136
+ def self.strip_gemma_thought(content)
137
+ content.gsub(GEMMA_THOUGHT_BLOCK, "")
138
+ end
139
+
87
140
  def self.format_with_prefixes(messages, profile, suffixes)
88
141
  # Qwen 3.6 style: <|im_start|>ROLE\n...<|im_end|>
89
142
  parts = []
@@ -117,5 +170,8 @@ module Samagotchi
117
170
  role = ESCAPED_KINDS.include?(message[:kind].to_s) ? "user" : message[:role]
118
171
  PromptLiteralGuard.escape(message[:content], profile: profile, role: role)
119
172
  end
173
+
174
+ private_class_method :image_lines, :format_with_turn_markers, :ends_in_tool_response?, :tool_response_blocks,
175
+ :strip_gemma_thought, :format_with_prefixes, :prompt_content_for
120
176
  end
121
177
  end
@@ -8,7 +8,7 @@ module Samagotchi
8
8
  module PromptLiteralGuard
9
9
  GEMMA_TOKEN_PAIRS = [
10
10
  ["[[SAMAGOTCHI_LITERAL_TURN_START]]", "<|turn>"],
11
- ["[[SAMAGOTCHI_LITERAL_TURN_END]]", "<end_of_turn>"],
11
+ ["[[SAMAGOTCHI_LITERAL_TURN_END]]", "<turn|>"],
12
12
  ["[[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN]]", "<|tool_call>"],
13
13
  ["[[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE]]", "<tool_call|>"],
14
14
  ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_OPEN]]", "<|tool_response>"],
@@ -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
@@ -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?