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
@@ -32,10 +32,7 @@ module Samagotchi
32
32
 
33
33
  key = model_name.to_s
34
34
  .downcase
35
- .gsub(/[^a-z0-9]+/, "-")
36
- .gsub(/-+/, "-")
37
- .sub(/\A-/, "")
38
- .sub(/-\z/, "")
35
+ .gsub(/[^a-z0-9]+/, "-").squeeze("-").delete_prefix("-").delete_suffix("-")
39
36
  key.empty? ? nil : key
40
37
  end
41
38
 
@@ -43,12 +40,13 @@ module Samagotchi
43
40
  # Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
44
41
  def self.overlay_path_for(name, key, scope)
45
42
  return nil unless key && !key.to_s.strip.empty?
43
+
46
44
  File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
47
45
  end
48
46
 
49
47
  # Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
50
48
  # Useful for tooling, tests, and future index suppression.
51
- OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
49
+ OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9-]+)\.md\z/
52
50
 
53
51
  # The base file name (`tips.md`) an overlay-looking name
54
52
  # (`tips.qwen3.md`) would belong to, or nil when it doesn't match the
@@ -17,10 +17,13 @@ module Samagotchi
17
17
  MODEL_FROM_CLI_ENV = "SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI"
18
18
  # No model anywhere (--model, default.model in config.yml, the env): a
19
19
  # first run before any config. An ArgumentError, as before.
20
- MissingModel = Class.new(ArgumentError)
20
+ class MissingModel < ArgumentError
21
+ end
22
+
21
23
  # A host-qualified model whose host isn't configured (a MissingModel,
22
24
  # so every surface that reports a missing model reports it the same way).
23
- UnknownHost = Class.new(MissingModel)
25
+ class UnknownHost < MissingModel
26
+ end
24
27
 
25
28
  # Gemma 4 thinks in a channel: `<|channel>thought` … `<channel|>`. The
26
29
  # one source for the parser, the stream splitter, the literal guard and
@@ -74,7 +77,7 @@ module Samagotchi
74
77
  new(
75
78
  name: "gemma4",
76
79
  turn_start: "<|turn>",
77
- turn_end: "<end_of_turn>",
80
+ turn_end: "<turn|>",
78
81
  tool_call_open: "<|tool_call>",
79
82
  tool_call_close: "<tool_call|>",
80
83
  tool_response_open: "<|tool_response>",
@@ -88,7 +91,7 @@ module Samagotchi
88
91
  user_prefix: "",
89
92
  assistant_prefix: "",
90
93
  model_prefix: "",
91
- stop_sequences: ["<end_of_turn>", "<|tool_response>"],
94
+ stop_sequences: ["<turn|>", "<|tool_response>"],
92
95
  tool_decl_format: :gemma4
93
96
  )
94
97
  end
@@ -165,14 +168,15 @@ module Samagotchi
165
168
  if !parsed.host_name && (disabled = disabled_host_prefix(ref, hosts, env))
166
169
  raise UnknownHost, "host '#{disabled}' is disabled (enabled: false in config.yml)"
167
170
  end
171
+
168
172
  warn_host_slash(ref, hosts) unless parsed.host_name
169
173
  host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
170
174
  return model_name unless host
171
175
 
172
176
  names = hosts.keys.map { |k| k.to_s.downcase }.sort
173
177
  near = Samagotchi::Config.near_names(host, names).first(3)
174
- hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(' or ')}?)"
175
- raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
178
+ hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(" or ")}?)"
179
+ raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
176
180
  end
177
181
 
178
182
  # The prefix of "box:x" when box is a host config.yml has with
@@ -275,7 +279,7 @@ module Samagotchi
275
279
  FINGERPRINTS.each do |name, markers|
276
280
  return [name, markers.join(" + ")] if markers.all? { |marker| template.include?(marker) }
277
281
  end
278
- return ["qwen36", "ChatML"] if template.include?("<|im_start|>")
282
+ return %w[qwen36 ChatML] if template.include?("<|im_start|>")
279
283
 
280
284
  nil
281
285
  end
@@ -341,7 +345,7 @@ module Samagotchi
341
345
 
342
346
  def self.configured(value, where)
343
347
  profile = named(value)
344
- Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(', ')}) — ignored", profile: value.to_s, where: where) unless profile
348
+ Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(", ")}) — ignored", profile: value.to_s, where: where) unless profile
345
349
  profile
346
350
  end
347
351
  private_class_method :configured
@@ -92,6 +92,7 @@ module Samagotchi
92
92
  # passes down may never close: waiting on either would hang a script.
93
93
  def read_stdin
94
94
  return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
95
+
95
96
  if @stdin.respond_to?(:stat)
96
97
  stat = @stdin.stat
97
98
  return nil unless stat.pipe? || stat.file?
@@ -135,7 +136,7 @@ module Samagotchi
135
136
  if owner
136
137
  @stdout.puts("#{short} queued: its worker adds it within a few seconds")
137
138
  else
138
- queued = SessionInbox.find_new_note_files(File.dirname(File.dirname(path))).size
139
+ queued = SessionInbox.find_new_note_files(File.dirname(path, 2)).size
139
140
  @stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
140
141
  end
141
142
  true
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Samagotchi
3
4
  # OutputFormatter strips model wire-format tokens from raw engine output at
4
5
  # render time, so downstream renderers (web UI, terminal UI) never show the
@@ -23,7 +24,6 @@ module Samagotchi
23
24
  # wrapper forms; bare <name> content (no pipe) is deliberately preserved.
24
25
  CONTROL_NAMES = %w[
25
26
  turn
26
- end_of_turn
27
27
  tool_call
28
28
  tool_response
29
29
  tool
@@ -40,31 +40,32 @@ module Samagotchi
40
40
  # <word> in the surrounding content is NOT stripped:
41
41
  # <|name> / <name|> / <|name|>
42
42
  INDIVIDUAL_RE = Regexp.new(
43
- '<\\|(?:' + CONTROL_NAMES.join('|') + ')\\|?>' \
44
- '|<(?:' + CONTROL_NAMES.join('|') + ')\\|>'
43
+ '<\\|(?:' + CONTROL_NAMES.join("|") + ')\\|?>' \
44
+ "|<(?:" + CONTROL_NAMES.join("|") + ')\\|>'
45
45
  )
46
46
  INDIVIDUAL_RE.freeze
47
47
 
48
48
  # Angle-bracketed literal tokens stripped wholesale (including the thought
49
49
  # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
50
50
  # are removed as a unit. Pipes are escaped; angle brackets are literal.
51
- LITERALS_RE = /<\|"\|>|<end_of_turn>/.freeze
51
+ LITERALS_RE = /<\|"\|>/
52
52
 
53
53
  # Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
54
54
  # `<channel|>` close, or to the end of the text when the close is missing
55
55
  # (ToolCallParser::Gemma strips the same pair). Removing only the marker
56
56
  # would leave the model's private reasoning in the answer; the channel is
57
57
  # the profile's thinking lane, not output.
58
- GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m.freeze
58
+ GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m
59
59
 
60
60
  # Qwen prompt-literal placeholders, e.g. </think>.
61
- PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
61
+ PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/
62
62
 
63
63
  # Qwen3.6 thinking blocks: <think>...</think> (including empty)
64
- QWEN_THINK_RE = /<think>.*?<\/think>/m.freeze
64
+ QWEN_THINK_RE = %r{<think>.*?</think>}m
65
65
  # Qwen tool_call XML blocks that survive when a tool call is rendered as text
66
66
  # e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
67
- QWEN_TOOL_CALL_RE = /<tool_call>.*?<\/tool_call>/m.freeze
67
+ QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
68
+
68
69
  module_function
69
70
 
70
71
  # Remove both control-token and prompt-literal token families from +text+.
@@ -79,7 +80,7 @@ module Samagotchi
79
80
  .gsub(/[ \t]+/, " ")
80
81
  .gsub(/ *\n */, "\n")
81
82
  .strip
82
- cleaned.empty? ? '' : cleaned
83
+ cleaned.empty? ? "" : cleaned
83
84
  end
84
85
 
85
86
  # The same tokens as #strip, but the text keeps its layout (indentation,
@@ -91,15 +92,15 @@ module Samagotchi
91
92
 
92
93
  def remove_tokens(text)
93
94
  text.to_s
94
- .gsub(QWEN_THINK_RE, '')
95
- .gsub(GEMMA_THOUGHT_RE, '')
96
- .gsub(QWEN_TOOL_CALL_RE, '')
97
- .gsub(LITERALS_RE, '')
98
- .gsub(INDIVIDUAL_RE, '')
99
- .gsub(PROMPT_LITERALS, '')
95
+ .gsub(QWEN_THINK_RE, "")
96
+ .gsub(GEMMA_THOUGHT_RE, "")
97
+ .gsub(QWEN_TOOL_CALL_RE, "")
98
+ .gsub(LITERALS_RE, "")
99
+ .gsub(INDIVIDUAL_RE, "")
100
+ .gsub(PROMPT_LITERALS, "")
100
101
  # orphaned closing tags that may remain after block removal
101
- .gsub(/<\/?think>/, '')
102
- .gsub(/<\/?tool_call>/, '')
102
+ .gsub(%r{</?think>}, "")
103
+ .gsub(%r{</?tool_call>}, "")
103
104
  end
104
105
  end
105
106
  end
@@ -40,7 +40,8 @@ module Samagotchi
40
40
  # @return [OwnerLock, nil] nil when another owner holds it
41
41
  def self.acquire(session_dir, kind:, wait: DEFAULT_WAIT)
42
42
  FileUtils.mkdir_p(session_dir)
43
- file = File.open(path(session_dir), File::RDWR | File::CREAT, 0o644)
43
+ # Held open on purpose: the flock lives as long as the OwnerLock (#release closes it).
44
+ file = File.open(path(session_dir), File::RDWR | File::CREAT, 0o644) # rubocop:disable Style/FileOpen
44
45
  deadline = monotonic_now + wait.to_f
45
46
  until file.flock(File::LOCK_EX | File::LOCK_NB)
46
47
  if monotonic_now >= deadline
@@ -70,8 +70,10 @@ module Samagotchi
70
70
 
71
71
  # @param session_id [String]
72
72
  # @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
73
+ # @param extra [Hash] more keys for the object (chi answer's
74
+ # answered_here: false)
73
75
  # @return [Hash] the --format json object
74
- def json(result, session_id:, timeout: nil)
76
+ def json(result, session_id:, timeout: nil, extra: {})
75
77
  report = { status: status(result), session_id: session_id }
76
78
  case report[:status]
77
79
  when "answered" then report[:text] = result.text.to_s
@@ -79,24 +81,39 @@ module Samagotchi
79
81
  question = question(result.question)
80
82
  report[:question] = question
81
83
  report[:answer_with] = answer_with(session_id, question)
82
- else report[:detail] = detail(result, session_id: session_id, timeout: timeout)
84
+ else
85
+ report.merge!(stop_facts(result))
86
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout)
87
+ end
88
+ report.merge(extra)
89
+ end
90
+
91
+ # Why a failed or canceled turn stopped, as far as last_turn knows
92
+ # (ReplyWait::Result): error_kind and retryable, cancel_reason and
93
+ # stopped_by; absent when unknown.
94
+ # @return [Hash]
95
+ def stop_facts(result)
96
+ case status(result)
97
+ when "failed" then { error_kind: result.error_kind, retryable: result.retryable }.compact
98
+ when "canceled" then { cancel_reason: result.cancel_reason, stopped_by: result.stopped_by }.compact
99
+ else {}
83
100
  end
84
- report
85
101
  end
86
102
 
87
103
  # @return [String] the JSON object as one line
88
- def json_line(result, session_id:, timeout: nil)
89
- JSON.generate(json(result, session_id: session_id, timeout: timeout))
104
+ def json_line(result, session_id:, timeout: nil, extra: {})
105
+ JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
90
106
  end
91
107
 
92
108
  # Print a wait's end the way the commands do: the JSON object on
93
109
  # stdout, or the reply on stdout and anything else on stderr (a
94
110
  # question in full) after "chi send: ".
95
111
  # @param command [String] "chi send", "chi answer"
112
+ # @param extra [Hash] more keys for the JSON object (#json)
96
113
  # @return [Integer] the exit status
97
- def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
114
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
98
115
  if json
99
- stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
116
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
100
117
  elsif result.status == :done
101
118
  stdout.puts(result.text)
102
119
  else
@@ -51,6 +51,7 @@ module Samagotchi
51
51
  # it runs as other commands do)
52
52
  def command(name, description, anytime: false, &block)
53
53
  raise ArgumentError, "command #{name.inspect} needs a block" unless block
54
+
54
55
  name = name.to_s
55
56
  raise ArgumentError, "command name #{name.inspect} must look like /name (a-z, 0-9, _ and -)" unless name.match?(COMMAND_NAME)
56
57
  if (taken = @registries.commands.entries.find { |entry| entry.name == name })
@@ -78,9 +79,9 @@ module Samagotchi
78
79
  # @param targets [#call, nil] args → what guardrail rules match: a Hash
79
80
  # with paths: (absolute or relative to the cwd), command: (a shell
80
81
  # command) and cwd:, each optional
81
- def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
82
+ def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
82
83
  spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
83
- targets: targets, &block)
84
+ targets: targets, &)
84
85
  if (taken = @registries.tools[spec[:name]])
85
86
  raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
86
87
  end
@@ -124,9 +125,9 @@ module Samagotchi
124
125
  end
125
126
 
126
127
  # As Api#tool.
127
- def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
128
+ def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
128
129
  spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
129
- targets: targets, &block)
130
+ targets: targets, &)
130
131
  raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
131
132
 
132
133
  @specs << spec
@@ -159,6 +160,7 @@ module Samagotchi
159
160
  # @return [Service]
160
161
  def service(name, eager: false, &block)
161
162
  raise ArgumentError, "service #{name.inspect} needs a block" unless block
163
+
162
164
  name = "#{@bundle}:#{name}"
163
165
  raise ArgumentError, "service #{name} is registered twice" if @services.any? { |svc| svc.name == name }
164
166
  raise ArgumentError, "this chi has no services (plugins: false)" unless @registries.services
@@ -249,6 +251,7 @@ module Samagotchi
249
251
  # @return [Hash] {name:, schema:, label:, preview:, targets:, block:}
250
252
  def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
251
253
  raise ArgumentError, "tool #{name.inspect} needs a block" unless block
254
+
252
255
  name = name.to_s
253
256
  raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
254
257
  raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
@@ -323,7 +326,7 @@ module Samagotchi
323
326
  # (a call built without one, in a spec say: the call without its
324
327
  # name), string keys, typed by +parameters+, frozen.
325
328
  def self.args_of(call, parameters = nil)
326
- given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| key == :name || key == :args }
329
+ given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| %i[name args].include?(key) }
327
330
  Tools::Args.coerce(given, parameters).freeze
328
331
  end
329
332
 
@@ -107,9 +107,7 @@ module Samagotchi
107
107
  # $XDG_STATE_HOME/samagotchi/plugins/<bundle>/, created on first use.
108
108
  # @return [String]
109
109
  def data_dir
110
- @data_dir ||= begin
111
- File.join(Paths.state_dir(env: @env), "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
112
- end
110
+ @data_dir ||= File.join(Paths.state_dir(env: @env), "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
113
111
  end
114
112
 
115
113
  # The conversation so far, a frozen copy, without the system prompt.
@@ -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"
@@ -455,7 +454,7 @@ module Samagotchi
455
454
  def settle_standing(standing, answer, client_id)
456
455
  clear_saved_question
457
456
  emit({ type: :question_answered, id: answer[:id], answer: answer })
458
- standing[:on_answer].call(answer.except(:by), client_id: client_id)
457
+ standing[:on_answer].call(answer, client_id: client_id)
459
458
  rescue StandardError => e
460
459
  Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
461
460
  end
@@ -469,14 +468,9 @@ 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?
475
- # A step-limit question: Continue resumes the turn as it was; a text
476
- # goes with Stop (steering a continue is not a thing).
477
- if pending[:kind].to_s == CONTINUE_KIND && sel == [CONTINUE_OPTION] && fm
478
- raise ArgumentError, "Continue takes no text (a text goes with Stop)"
479
- end
480
474
 
481
475
  indices = sel.map { |v| opts.index(v) }
482
476
  # The one whose answer settles the approval (Approval.settle, by index).
@@ -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
@@ -33,7 +33,16 @@ module Samagotchi
33
33
  # "failed", text is the failure's summary when the turn note has one.
34
34
  # @!attribute limit [Integer, nil] the iteration limit an "exhausted"
35
35
  # turn ran out at
36
- Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, keyword_init: true)
36
+ # @!attribute error_kind [String, nil] a "failed" turn's provider error
37
+ # kind (credits, server, auth, …), from last_turn
38
+ # @!attribute retryable [Boolean, nil] whether that error may pass on a
39
+ # retry
40
+ # @!attribute cancel_reason [String, nil] a "canceled" turn's reason
41
+ # (user, hook, ctrl_c, manual)
42
+ # @!attribute stopped_by [String, nil] the hook that canceled it
43
+ # (loop-guard)
44
+ Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
45
+ :stopped_by, keyword_init: true)
37
46
 
38
47
  module_function
39
48
 
@@ -151,7 +160,8 @@ module Samagotchi
151
160
  return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
152
161
 
153
162
  text = outcome == "failed" ? failure_summary(session.messages) : nil
154
- Result.new(status: :no_reply, outcome: outcome, text: text)
163
+ why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
164
+ Result.new(status: :no_reply, outcome: outcome, text: text, **why)
155
165
  end
156
166
 
157
167
  # The summary in the failed-turn note at the conversation's tail, nil