samagotchi 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/bin/chi +9 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +13 -4
  6. data/docs/hooks.md +20 -6
  7. data/docs/plugins.md +1 -1
  8. data/docs/releasing.md +1 -1
  9. data/docs/sub-agent.md +4 -3
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +4 -2
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  13. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  14. data/lib/samagotchi/bootstrap_command.rb +2 -2
  15. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  16. data/lib/samagotchi/bridge/card_store.rb +4 -3
  17. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
  19. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +19 -9
  21. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  22. data/lib/samagotchi/bridge_client.rb +1 -1
  23. data/lib/samagotchi/bundle_command.rb +39 -29
  24. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
  28. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  29. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  32. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  36. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  37. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  38. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  39. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  40. data/lib/samagotchi/cancellation_controller.rb +1 -1
  41. data/lib/samagotchi/cli/command.rb +1 -1
  42. data/lib/samagotchi/client.rb +3 -5
  43. data/lib/samagotchi/commands/registry.rb +2 -2
  44. data/lib/samagotchi/config.rb +42 -14
  45. data/lib/samagotchi/context_note.rb +1 -1
  46. data/lib/samagotchi/context_status.rb +6 -6
  47. data/lib/samagotchi/continue_offer.rb +15 -8
  48. data/lib/samagotchi/debug_log.rb +1 -1
  49. data/lib/samagotchi/desktop/macos.rb +5 -4
  50. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  51. data/lib/samagotchi/engine.rb +97 -18
  52. data/lib/samagotchi/gem_update.rb +2 -1
  53. data/lib/samagotchi/generation_phase.rb +83 -0
  54. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  55. data/lib/samagotchi/guardrails/approval.rb +5 -5
  56. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  57. data/lib/samagotchi/guardrails/gate.rb +2 -2
  58. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  59. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  60. data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
  61. data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
  62. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  63. data/lib/samagotchi/hooks/loader.rb +18 -16
  64. data/lib/samagotchi/hooks/registry.rb +10 -2
  65. data/lib/samagotchi/host_registry.rb +1 -3
  66. data/lib/samagotchi/idle_recap.rb +22 -17
  67. data/lib/samagotchi/idle_reminders.rb +6 -3
  68. data/lib/samagotchi/image_store.rb +5 -2
  69. data/lib/samagotchi/kernel_loop.rb +50 -40
  70. data/lib/samagotchi/llm/chat_loop.rb +29 -16
  71. data/lib/samagotchi/llm/errors.rb +2 -2
  72. data/lib/samagotchi/llm/http.rb +12 -12
  73. data/lib/samagotchi/llm/openai_chat.rb +42 -28
  74. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  75. data/lib/samagotchi/log.rb +2 -2
  76. data/lib/samagotchi/log_subscriber.rb +5 -3
  77. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  78. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  79. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  80. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  81. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  82. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  83. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  84. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  85. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  86. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  87. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  88. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  89. data/lib/samagotchi/memory_bundle.rb +1 -0
  90. data/lib/samagotchi/model_overlay.rb +3 -5
  91. data/lib/samagotchi/model_profile.rb +12 -8
  92. data/lib/samagotchi/note_command.rb +2 -1
  93. data/lib/samagotchi/output_formatter.rb +18 -17
  94. data/lib/samagotchi/owner_lock.rb +2 -1
  95. data/lib/samagotchi/plugin/api.rb +8 -5
  96. data/lib/samagotchi/plugin/context.rb +1 -3
  97. data/lib/samagotchi/plugin/loader.rb +1 -1
  98. data/lib/samagotchi/plugin/service.rb +1 -1
  99. data/lib/samagotchi/plugin/sessions.rb +1 -0
  100. data/lib/samagotchi/plugin_tasks.rb +1 -1
  101. data/lib/samagotchi/prompt.rb +72 -13
  102. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  103. data/lib/samagotchi/question_desk.rb +7 -7
  104. data/lib/samagotchi/recap_setup.rb +1 -1
  105. data/lib/samagotchi/relay_desk.rb +1 -1
  106. data/lib/samagotchi/reminder_store.rb +2 -1
  107. data/lib/samagotchi/send_command.rb +4 -2
  108. data/lib/samagotchi/session.rb +2 -2
  109. data/lib/samagotchi/session_archive_command.rb +5 -1
  110. data/lib/samagotchi/session_commands.rb +1 -3
  111. data/lib/samagotchi/session_manager.rb +2 -4
  112. data/lib/samagotchi/session_metrics.rb +10 -10
  113. data/lib/samagotchi/session_observer.rb +3 -2
  114. data/lib/samagotchi/session_retention.rb +2 -2
  115. data/lib/samagotchi/sessions_command.rb +14 -5
  116. data/lib/samagotchi/steer.rb +103 -8
  117. data/lib/samagotchi/system_prompt.rb +19 -8
  118. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  119. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  120. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  121. data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
  122. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  123. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  124. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  125. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  126. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  127. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  128. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  129. data/lib/samagotchi/terminal_ui.rb +14 -5
  130. data/lib/samagotchi/text_diff.rb +12 -12
  131. data/lib/samagotchi/thinking.rb +6 -2
  132. data/lib/samagotchi/thought_stream_splitter.rb +15 -6
  133. data/lib/samagotchi/tool_activity.rb +1 -0
  134. data/lib/samagotchi/tool_call_parser.rb +13 -14
  135. data/lib/samagotchi/tool_declarations.rb +36 -35
  136. data/lib/samagotchi/tool_runner.rb +7 -0
  137. data/lib/samagotchi/tool_view.rb +40 -0
  138. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  139. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  140. data/lib/samagotchi/tools/delegate.rb +1 -1
  141. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  142. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  143. data/lib/samagotchi/tools/edit.rb +14 -13
  144. data/lib/samagotchi/tools/execute.rb +3 -5
  145. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  146. data/lib/samagotchi/tools/memory.rb +21 -22
  147. data/lib/samagotchi/tools/read.rb +7 -7
  148. data/lib/samagotchi/tools/task_create.rb +1 -1
  149. data/lib/samagotchi/tools/task_get.rb +1 -1
  150. data/lib/samagotchi/tools/task_list.rb +1 -1
  151. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  152. data/lib/samagotchi/tools/task_stop.rb +1 -1
  153. data/lib/samagotchi/tools/task_wait.rb +3 -4
  154. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  155. data/lib/samagotchi/tools/write.rb +1 -1
  156. data/lib/samagotchi/turn_flow.rb +8 -8
  157. data/lib/samagotchi/turn_note.rb +5 -1
  158. data/lib/samagotchi/version.rb +1 -1
  159. data/lib/samagotchi/vision_support.rb +2 -2
  160. data/lib/samagotchi/waiting_steer.rb +64 -0
  161. data/lib/samagotchi/web/app.rb +13 -6
  162. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  163. data/lib/samagotchi/web/message_parts.rb +5 -0
  164. data/lib/samagotchi/web/public/activity.js +5 -0
  165. data/lib/samagotchi/web/public/app.js +2 -1
  166. data/lib/samagotchi/web/public/command_view.js +28 -0
  167. data/lib/samagotchi/web/public/copy.js +7 -0
  168. data/lib/samagotchi/web/public/index.html +8 -0
  169. data/lib/samagotchi/web/public/stage_model.js +11 -4
  170. data/lib/samagotchi/web/public/stage_view.js +5 -2
  171. data/lib/samagotchi/web/public/turn_events.js +15 -1
  172. data/lib/samagotchi/web/public/turn_view.js +15 -6
  173. data/lib/samagotchi/web/qr.rb +7 -1
  174. data/lib/samagotchi/web/server.rb +6 -2
  175. data/lib/samagotchi/worker.rb +14 -3
  176. metadata +24 -20
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # What the running generation is doing, as the Engine's stream handler
5
+ # sees it: whether a steer may cut it (Engine#cut_for_steer). A
6
+ # generation is cuttable while it has only been thinking: it streamed
7
+ # thinking, still streams it (a thinking delta in the last FRESH_SECONDS),
8
+ # and has streamed no visible text and no tool call. Its age counts from
9
+ # its first thinking delta, so the wait for a first token (a busy or
10
+ # remote server) never counts as thinking. Fed from the stream
11
+ # thread, read from a steer's thread: its own Mutex, never held while
12
+ # calling out.
13
+ class GenerationPhase
14
+ # A thinking delta this recent means the model is still thinking.
15
+ FRESH_SECONDS = 2.0
16
+
17
+ # @param clock [#call] monotonic seconds
18
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
19
+ @clock = clock
20
+ @mutex = Mutex.new
21
+ @running = false
22
+ clear_seen
23
+ end
24
+
25
+ # A generation began.
26
+ def started!
27
+ @mutex.synchronize do
28
+ @running = true
29
+ clear_seen
30
+ end
31
+ end
32
+
33
+ # One streamed chunk's lanes. Blank text (a stray newline before Gemma's
34
+ # thought channel) is no text.
35
+ def chunk!(thinking:, text:, tool_call: false)
36
+ @mutex.synchronize do
37
+ unless thinking.to_s.empty?
38
+ @thinking_at = @clock.call
39
+ @thinking_since ||= @thinking_at
40
+ end
41
+ @text = true unless text.to_s.strip.empty?
42
+ @tool_call = true if tool_call
43
+ end
44
+ end
45
+
46
+ # The request is sent again (a network retry): it streams from the
47
+ # start, so what it had seen (its thinking clock too) is forgotten.
48
+ def retrying!
49
+ @mutex.synchronize { clear_seen }
50
+ end
51
+
52
+ # The generation ended (completed, cut, cancelled, or the turn ended).
53
+ def finished!
54
+ @mutex.synchronize { @running = false }
55
+ end
56
+
57
+ # Whether a steer may cut the running generation: thinking only, still
58
+ # thinking, and thinking for at least +min_age+ seconds.
59
+ def cuttable?(min_age)
60
+ @mutex.synchronize do
61
+ next false unless @running && @thinking_since && !@text && !@tool_call
62
+
63
+ now = @clock.call
64
+ now - @thinking_at <= FRESH_SECONDS && now - @thinking_since >= min_age
65
+ end
66
+ end
67
+
68
+ # Seconds since the running generation's first thinking delta; nil
69
+ # with none.
70
+ def age
71
+ @mutex.synchronize { @running && @thinking_since ? @clock.call - @thinking_since : nil }
72
+ end
73
+
74
+ private
75
+
76
+ def clear_seen
77
+ @thinking_since = nil
78
+ @thinking_at = nil
79
+ @text = false
80
+ @tool_call = false
81
+ end
82
+ end
83
+ end
@@ -159,7 +159,7 @@ module Samagotchi
159
159
  raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
160
160
  end
161
161
 
162
- doc = YAML.safe_load(File.read(path))
162
+ doc = YAML.safe_load_file(path)
163
163
  raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
164
164
 
165
165
  loaded.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
@@ -150,13 +150,13 @@ module Samagotchi
150
150
  return nil unless preview.is_a?(Hash)
151
151
 
152
152
  get = ->(key) { preview[key] || preview[key.to_s] }
153
- return "would fail: #{get.(:error)}" if get.(:error)
154
- return "not shown (#{get.(:skipped)})" if get.(:skipped)
153
+ return "would fail: #{get.call(:error)}" if get.call(:error)
154
+ return "not shown (#{get.call(:skipped)})" if get.call(:skipped)
155
155
 
156
- added = get.(:added).to_i
157
- return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.(:new_file)
156
+ added = get.call(:added).to_i
157
+ return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.call(:new_file)
158
158
 
159
- "+#{added} −#{get.(:removed).to_i}"
159
+ "+#{added} −#{get.call(:removed).to_i}"
160
160
  end
161
161
  end
162
162
  end
@@ -72,8 +72,8 @@ module Samagotchi
72
72
  list.find do |e|
73
73
  case e["scope"]
74
74
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
75
- when "repo" then here.(e) && e["key"] == key
76
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.(e)
75
+ when "repo" then here.call(e) && e["key"] == key
76
+ when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
77
77
  end
78
78
  end
79
79
  end
@@ -120,8 +120,8 @@ module Samagotchi
120
120
  end
121
121
 
122
122
  # Registry#fire_each rescues a raising hook itself.
123
- def fire_each(name, event, &after_each)
124
- @hooks_lookup.call&.fire_each(name, event, &after_each)
123
+ def fire_each(name, event, &)
124
+ @hooks_lookup.call&.fire_each(name, event, &)
125
125
  end
126
126
  end
127
127
  end
@@ -167,7 +167,7 @@ module Samagotchi
167
167
  program = nil
168
168
  until rest.empty?
169
169
  arg = rest.shift
170
- if arg == "-F" || arg == "-v" then rest.shift
170
+ if ["-F", "-v"].include?(arg) then rest.shift
171
171
  elsif arg.match?(/\A-[Fv]./m) then next
172
172
  elsif arg == "--" then program = rest.shift
173
173
  elsif arg.start_with?("-") then return false
@@ -296,14 +296,16 @@ module Samagotchi
296
296
  delim = @s.getch
297
297
  return false if delim.nil? || delim.match?(/[\s\\]/)
298
298
 
299
- delimited(delim) && delimited(delim) && @s.skip(/[gpiImM\d]*/) && true
299
+ # Two calls, two fields: each one advances the scanner (pattern, then replacement).
300
+ delimited(delim) && delimited(delim) && @s.skip(/[gpiImM\d]*/) && true # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
300
301
  end
301
302
 
302
303
  def transliterate
303
304
  delim = @s.getch
304
305
  return false if delim.nil? || delim.match?(/[\s\\]/)
305
306
 
306
- delimited(delim) && delimited(delim)
307
+ # Source set, then target set: each call advances the scanner.
308
+ delimited(delim) && delimited(delim) # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
307
309
  end
308
310
 
309
311
  # Up to the next unescaped +delim+ (consumed); false at the end.
@@ -123,7 +123,7 @@ module Samagotchi
123
123
  def expand(arg, dir)
124
124
  return nil if arg.include?(ShellLex::SUBST)
125
125
 
126
- arg = arg.sub(/\A(~|\$HOME|\$\{HOME\})(?=\/|\z)/) { @home }
126
+ arg = arg.sub(%r{\A(~|\$HOME|\$\{HOME\})(?=/|\z)}) { @home }
127
127
  return nil if arg.include?("$") || arg.start_with?("~")
128
128
  return File.expand_path(arg) if arg.start_with?("/")
129
129
 
@@ -5,13 +5,23 @@ module Samagotchi
5
5
  # A small shell lexer for the guardrails' text checks (ShellGitDirs
6
6
  # and the rules' shell matchers): words with quotes and
7
7
  # backslashes undone, operators apart, comments dropped, and $(…) or
8
- # backticks (quoted or not) as one opaque SUBST marker inside a word. It never runs
9
- # anything, and it does its best on odd input (an unbalanced quote).
8
+ # backticks (quoted or not) as one opaque SUBST marker inside a word. A
9
+ # heredoc's body (<<EOF, <<'EOF', <<-EOF) is one word after its <<TAG
10
+ # word and lexing goes on after the terminator line: HEREDOC when cat or
11
+ # tee read it as data, SUBST when an unquoted tag's body holds $(…) or
12
+ # backticks, else the body text itself (a script, as sh -c '…' gives).
13
+ # An unterminated heredoc is no heredoc: the rest is lexed as commands.
14
+ # It never runs anything, and it does its best on odd input (an
15
+ # unbalanced quote).
10
16
  module ShellLex
11
17
  # Longest first: "&&" before "&", ";;" before ";".
12
18
  OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
13
19
  # What a $(…) or `…` becomes inside a word.
14
20
  SUBST = "\0SUBST"
21
+ # What a heredoc body that cat or tee read as data becomes.
22
+ HEREDOC = "\0HEREDOC"
23
+ # Commands that read a heredoc as data (when not piped on).
24
+ DATA_SINKS = %w[cat tee].freeze
15
25
  # A word that is a redirection (>, >>, 2>&1, <).
16
26
  REDIRECT = /\A\d*[<>]/
17
27
 
@@ -50,6 +60,7 @@ module Samagotchi
50
60
  @word = +""
51
61
  @specials = +""
52
62
  @in_word = false
63
+ @heredocs = []
53
64
  end
54
65
 
55
66
  def tokens
@@ -139,15 +150,19 @@ module Samagotchi
139
150
  @i = @s.index("\n", @i) || @s.size
140
151
  end
141
152
 
142
- # >, >>, 2>&1, &>, <, <<: kept in the word (the walk drops them).
153
+ # >, >>, 2>&1, &>, <, <<<: kept in the word (the walk drops them).
154
+ # << starts a heredoc.
143
155
  def redirection
156
+ return add("<<<", 3) if @s[@i, 3] == "<<<"
157
+ return if @s[@i, 2] == "<<" && heredoc
158
+
144
159
  special(@s[@i])
145
- add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.match?(/>\z/)
160
+ add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.end_with?(">")
146
161
  end
147
162
 
148
163
  def operator_or_char(char)
149
164
  op = OPERATORS.find { |o| @s[@i, o.size] == o }
150
- return special(char) if char == "{" || char == "}"
165
+ return special(char) if ["{", "}"].include?(char)
151
166
  return add(char, 1) unless op
152
167
 
153
168
  if op == "&" && @word.match?(/\d*>\z/)
@@ -156,8 +171,101 @@ module Samagotchi
156
171
  flush
157
172
  @tokens << [:op, op]
158
173
  @i += op.size
174
+ bodies if op == "\n" && !@heredocs.empty?
159
175
  end
160
176
  end
177
+
178
+ # <<TAG, <<-TAG, <<'TAG', << "TAG": its own word, the body noted to
179
+ # be read after the line ends. False (nothing read) without a tag.
180
+ def heredoc
181
+ dash = @s[@i + 2] == "-"
182
+ j = @i + (dash ? 3 : 2)
183
+ j += 1 while [" ", "\t"].include?(@s[j])
184
+ tag, quoted, stop = heredoc_tag(j)
185
+ return false if tag.empty?
186
+
187
+ flush unless @word.match?(/\A\d*\z/)
188
+ special("<")
189
+ @word << "<" << (dash ? "-" : "") << tag
190
+ @i = stop
191
+ flush
192
+ @heredocs << { tag: tag, dash: dash, quoted: quoted, at: @tokens.size }
193
+ true
194
+ end
195
+
196
+ # The tag starting at +j+ with quotes and backslashes undone, whether
197
+ # any were there, and where it ends.
198
+ def heredoc_tag(j)
199
+ tag = +""
200
+ quoted = false
201
+ while j < @s.size && !@s[j].match?(/[\s;&|<>()]/)
202
+ if ["'", '"'].include?(@s[j])
203
+ close = @s.index(@s[j], j + 1) or return ["", false, j]
204
+ tag << @s[(j + 1)...close]
205
+ j = close + 1
206
+ quoted = true
207
+ elsif @s[j] == "\\"
208
+ tag << @s[j + 1].to_s
209
+ j += 2
210
+ quoted = true
211
+ else
212
+ tag << @s[j]
213
+ j += 1
214
+ end
215
+ end
216
+ [tag, quoted, j]
217
+ end
218
+
219
+ # After a line with heredocs: each body in order, up to its
220
+ # terminator line, as one word in its command. Unterminated: none
221
+ # are, and the rest is lexed as commands.
222
+ def bodies
223
+ heredocs = @heredocs
224
+ @heredocs = []
225
+ pos = @i
226
+ read = []
227
+ heredocs.each do |doc|
228
+ body, pos = body(doc, pos)
229
+ break unless body
230
+
231
+ read << [doc, body]
232
+ end
233
+ return if read.size < heredocs.size
234
+
235
+ read.reverse_each { |doc, body| @tokens.insert(doc[:at], [:word, body_word(doc, body), ""]) }
236
+ @i = pos
237
+ end
238
+
239
+ # The body from +pos+ and where lexing goes on, or nil.
240
+ def body(doc, pos)
241
+ start = pos
242
+ while pos < @s.size
243
+ eol = @s.index("\n", pos) || @s.size
244
+ line = @s[pos...eol]
245
+ line = line.sub(/\A\t+/, "") if doc[:dash]
246
+ return [@s[start...pos], [eol + 1, @s.size].min] if line == doc[:tag]
247
+
248
+ pos = eol + 1
249
+ end
250
+ nil
251
+ end
252
+
253
+ def body_word(doc, body)
254
+ return SUBST if !doc[:quoted] && body.match?(/\$\(|`/)
255
+ return HEREDOC if data_sink?(doc[:at])
256
+
257
+ body
258
+ end
259
+
260
+ # Whether the command the heredoc at token +at+ is in is cat or tee
261
+ # and not piped into another command.
262
+ def data_sink?(at)
263
+ start = at
264
+ start -= 1 while start.positive? && @tokens[start - 1][0] == :word
265
+ verb = @tokens[start...at].map { |t| t[1] }.find { |w| !w.match?(/\A[A-Za-z_]\w*=/) && !w.match?(REDIRECT) }
266
+ next_op = @tokens[at..].find { |t| t[0] == :op }
267
+ DATA_SINKS.include?(File.basename(verb.to_s)) && !["|", "|&"].include?(next_op&.dig(1))
268
+ end
161
269
  end
162
270
  end
163
271
  end
@@ -107,14 +107,14 @@ module Samagotchi
107
107
  def candidate(word)
108
108
  word = word.sub(REDIRECT_PREFIX, "")
109
109
  word = word.split("=", 2).last if word.match?(/\A(--?[\w-]+|[A-Za-z_]\w*)=/)
110
- return nil if word.empty? || word == "-" || word.match?(/\A-[^\/]/)
110
+ return nil if word.empty? || word == "-" || word.match?(%r{\A-[^/]})
111
111
 
112
112
  word
113
113
  end
114
114
 
115
115
  # An absolute path for +word+ against +dir+, or nil.
116
116
  def resolve(word, dir)
117
- return nil if word.include?(ShellLex::SUBST) || word.match?(SCRIPT)
117
+ return nil if word.include?(ShellLex::SUBST) || word == ShellLex::HEREDOC || word.match?(SCRIPT)
118
118
 
119
119
  word = expand(word) or return nil
120
120
  return nil if word.match?(GLOB)
@@ -124,7 +124,7 @@ module Samagotchi
124
124
  end
125
125
 
126
126
  def expand(word)
127
- word = word.sub(/\A~(?=\/|\z)/) { @home }
127
+ word = word.sub(%r{\A~(?=/|\z)}) { @home }
128
128
  word = word.sub(EXPANSION) { value(Regexp.last_match(1) || Regexp.last_match(2)) || "$" }
129
129
  return nil if word.include?("$") || word.start_with?("~")
130
130
 
@@ -65,13 +65,13 @@ module Samagotchi
65
65
  next if basename.empty?
66
66
 
67
67
  meta = ->(key) { raw_meta && (raw_meta[key] || raw_meta[key.to_s]) }
68
- event = meta.(:event).to_s
68
+ event = meta.call(:event).to_s
69
69
  # A hook with no declared event cannot be auto-registered.
70
70
  next if event.empty?
71
71
 
72
72
  event_sym = event.to_sym
73
- on_error = (meta.(:on_error) || "skip").to_s
74
- priority = (meta.(:priority) || 100).to_i
73
+ on_error = (meta.call(:on_error) || "skip").to_s
74
+ priority = (meta.call(:priority) || 100).to_i
75
75
  fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
76
76
  what = "hook #{basename} (bundle #{bundle_name})"
77
77
  file = hooks_dir && File.join(hooks_dir, basename)
@@ -79,7 +79,7 @@ module Samagotchi
79
79
  failures&.add(what, "the file is missing", required: true) if fail_closed
80
80
  next
81
81
  end
82
- if (mismatch = too_old || sha_mismatch(file, meta.(:sha256), required: fail_closed))
82
+ if (mismatch = too_old || sha_mismatch(file, meta.call(:sha256), required: fail_closed))
83
83
  Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
84
84
  failures&.add(what, mismatch, required: fail_closed)
85
85
  next
@@ -91,12 +91,14 @@ module Samagotchi
91
91
  elsif on_error == "log" then :log
92
92
  else :skip
93
93
  end
94
- label = "#{fail_closed ? "guardrail " : ""}#{basename} (bundle #{bundle_name})"
94
+ label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
95
95
  handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
96
96
  fields: { bundle: bundle_name }) { |event| plugin.call(event) }
97
97
  registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority, &handler)
98
98
  loaded += 1
99
- rescue Exception => e
99
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
100
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
101
+
100
102
  Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
101
103
  failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed)
102
104
  end
@@ -127,6 +129,7 @@ module Samagotchi
127
129
  klass = Hooks.class_for(basename, ns)
128
130
  instance = Hooks.build_plugin(klass, settings)
129
131
  raise ArgumentError, "plugin #{klass.name.split("::").last} does not respond to #call" unless instance.respond_to?(:call)
132
+
130
133
  instance
131
134
  end
132
135
 
@@ -67,17 +67,15 @@ module Samagotchi
67
67
  definitions = parse_definitions(hooks_config)
68
68
 
69
69
  definitions.each do |defn|
70
- begin
71
- plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
72
- # Persistent: config hooks must fire on every turn, not be wiped
73
- # by Engine#run_turn's per-turn clear_hooks after turn 1.
74
- registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)",
75
- &wrapped(plugin, defn))
76
- rescue ScriptError, StandardError => e
77
- # ScriptError: a SyntaxError (or LoadError) from `require`.
78
- Log.error(:hooks, "hook_load_failed", echo: "[samagotchi:hooks] hook #{defn[:path]} failed to load: #{e.class}: #{e.message}", hook: defn[:path].to_s, error: e.class.name)
79
- failures&.add("hook #{defn[:path]} (config)", "#{e.class}: #{e.message}", required: defn[:required])
80
- end
70
+ plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
71
+ # Persistent: config hooks must fire on every turn, not be wiped
72
+ # by Engine#run_turn's per-turn clear_hooks after turn 1.
73
+ registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)",
74
+ &wrapped(plugin, defn))
75
+ rescue ScriptError, StandardError => e
76
+ # ScriptError: a SyntaxError (or LoadError) from `require`.
77
+ Log.error(:hooks, "hook_load_failed", echo: "[samagotchi:hooks] hook #{defn[:path]} failed to load: #{e.class}: #{e.message}", hook: defn[:path].to_s, error: e.class.name)
78
+ failures&.add("hook #{defn[:path]} (config)", "#{e.class}: #{e.message}", required: defn[:required])
81
79
  end
82
80
 
83
81
  registry
@@ -87,6 +85,7 @@ module Samagotchi
87
85
  # Expand a path that may start with ~.
88
86
  def self.expand_path(path, env = ENV)
89
87
  return path unless path.start_with?("~")
88
+
90
89
  home = env["HOME"] || Dir.home
91
90
  File.join(home, path[1..])
92
91
  end
@@ -97,8 +96,10 @@ module Samagotchi
97
96
  definitions = []
98
97
  hooks_config.each do |event_type, configs|
99
98
  next unless event_type.to_s != "hooks_dir" && configs.is_a?(Array)
99
+
100
100
  configs.each do |cfg|
101
101
  next unless cfg.is_a?(Hash) && cfg["path"]
102
+
102
103
  definitions << {
103
104
  event_type: event_type.to_s,
104
105
  path: cfg["path"],
@@ -123,9 +124,7 @@ module Samagotchi
123
124
 
124
125
  # Check if already loaded (Ruby's require caching handles this)
125
126
  # We cache the instance separately to avoid re-instantiating
126
- unless @plugin_cache
127
- @plugin_cache = {}
128
- end
127
+ @plugin_cache ||= {}
129
128
 
130
129
  @plugin_cache[[full_path, settings]] ||= begin
131
130
  require full_path
@@ -133,6 +132,7 @@ module Samagotchi
133
132
  instance = Hooks.build_plugin(klass, settings)
134
133
  # Validate that the instance responds to #call
135
134
  raise ArgumentError, "Plugin #{klass.name} does not respond to #call" unless instance.respond_to?(:call)
135
+
136
136
  instance
137
137
  end
138
138
  end
@@ -142,9 +142,11 @@ module Samagotchi
142
142
  # on_error: log warns and skip is silent.
143
143
  def self.wrapped(plugin, defn)
144
144
  if defn[:required] && defn[:event_type] == "before_tool_call"
145
- policy, label = :deny, "required hook #{defn[:path]}"
145
+ policy = :deny
146
+ label = "required hook #{defn[:path]}"
146
147
  else
147
- policy, label = (defn[:on_error] == "log" ? :log : :skip), "#{defn[:path]} (config)"
148
+ policy = (defn[:on_error] == "log" ? :log : :skip)
149
+ label = "#{defn[:path]} (config)"
148
150
  end
149
151
  Hooks.wrap(label: label, event: defn[:event_type].to_sym, policy: policy) { |event| plugin.call(event) }
150
152
  end
@@ -13,7 +13,11 @@ module Samagotchi
13
13
  # @param settings [Hash]
14
14
  def self.build_plugin(klass, settings)
15
15
  takes_settings = klass.instance_method(:initialize).arity != 0
16
- takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
16
+ if takes_settings
17
+ klass.new(settings.is_a?(Hash) ? settings : {})
18
+ else
19
+ klass.new
20
+ end
17
21
  end
18
22
 
19
23
  # A stream hook (:generation_progress) fires ~once a second: a broken
@@ -62,7 +66,7 @@ module Samagotchi
62
66
  def self.wrap(label:, event:, policy:, log: [:hooks, "hook_failed"], echo: true, fields: {}, &block)
63
67
  log_failure = failure_log_gate(event)
64
68
  lambda do |payload|
65
- block.call(payload)
69
+ yield(payload)
66
70
  rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
67
71
  raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
68
72
 
@@ -130,6 +134,7 @@ module Samagotchi
130
134
  def register(name, &block)
131
135
  raise ArgumentError, "hook name must be a Symbol" unless name.is_a?(Symbol)
132
136
  raise ArgumentError, "hook block is required" unless block_given?
137
+
133
138
  @mutex.synchronize { (@hooks[name] ||= []) << block }
134
139
  end
135
140
 
@@ -144,6 +149,7 @@ module Samagotchi
144
149
  def register_persistent(name, label: nil, &block)
145
150
  raise ArgumentError, "hook name must be a Symbol" unless name.is_a?(Symbol)
146
151
  raise ArgumentError, "hook block is required" unless block_given?
152
+
147
153
  label = label.to_s.strip
148
154
  label = CONFIG_HOOK_LABEL if label.empty?
149
155
  @mutex.synchronize { (@persistent_hooks[name] ||= []) << { label: label, proc: block } }
@@ -160,6 +166,7 @@ module Samagotchi
160
166
  # @return [void]
161
167
  def register_bundle(bundle_name, event_name, hook_name:, priority: 100, &block)
162
168
  raise ArgumentError, "hook block is required" unless block_given?
169
+
163
170
  @mutex.synchronize do
164
171
  (@bundle_hooks[event_name] ||= []) << {
165
172
  bundle: bundle_name,
@@ -180,6 +187,7 @@ module Samagotchi
180
187
  if event_name
181
188
  arr = @bundle_hooks[event_name]
182
189
  return 0 unless arr
190
+
183
191
  before = arr.size
184
192
  @bundle_hooks[event_name] = arr.reject { |h| h[:bundle] == bundle_name }
185
193
  removed = before - @bundle_hooks[event_name].size
@@ -118,9 +118,7 @@ module Samagotchi
118
118
  @model_index = nil # downcased model_id => [host_name] in hosts: order
119
119
  end
120
120
 
121
- def entries
122
- @entries
123
- end
121
+ attr_reader :entries
124
122
 
125
123
  # Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
126
124
  LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
@@ -46,7 +46,7 @@ module Samagotchi
46
46
  module TranscriptFilter
47
47
  # A tool call as the model wrote it inline: gemma, qwen, or the qwen
48
48
  # prompt-literal form. An unterminated gemma call runs to the end.
49
- TOOL_CALL_RE = /<\|tool_call>(?:.*?<tool_call\|>|.*\z)|<tool_call>.*?<\/tool_call>|\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN\]\].*?\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE\]\]/m
49
+ TOOL_CALL_RE = %r{<\|tool_call>(?:.*?<tool_call\|>|.*\z)|<tool_call>.*?</tool_call>|\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN\]\].*?\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE\]\]}m
50
50
  # Each dispatched call's output starts with "[name]"; the kernel loop
51
51
  # joins one step's outputs into a single tool_response with this
52
52
  # separator, while the chat loop writes one message per call.
@@ -63,8 +63,10 @@ module Samagotchi
63
63
 
64
64
  case message["role"]
65
65
  when "user"
66
- # A plugin's steer is a prod to the model, not what the user said.
67
- next nil if Steer.steer?(message)
66
+ # A plugin's steer is a prod to the model, not what the user said;
67
+ # a continue steer from the user or the parent agent is an
68
+ # instruction, kept.
69
+ next nil if Steer.steer?(message) && !Steer.person?(message)
68
70
 
69
71
  # An image is a line naming it (refs only, never its bytes), unless
70
72
  # the text is only the web's placeholder naming it already.
@@ -215,7 +217,7 @@ module Samagotchi
215
217
  system = format(SYSTEM, range) + (previous ? UPDATE + format(UPDATE_LENGTH, range) : "") + tools
216
218
  user = +""
217
219
  user << "Earlier recap:\n#{previous}\n\n" if previous
218
- user << "Transcript#{previous ? ' since the earlier recap' : ''} (the system prompt and tool " \
220
+ user << "Transcript#{" since the earlier recap" if previous} (the system prompt and tool " \
219
221
  "internals were removed; only user turns and assistant prose remain):\n---\n#{body}\n---\n"
220
222
  user << "#{OFFER_LINE}\n" if offer
221
223
  user << "Write the recap now."
@@ -225,7 +227,7 @@ module Samagotchi
225
227
  # "2-4 sentences", or "3 sentences" for [3, 3] ("1 sentence" for [1, 1])
226
228
  def sentences_text((min, max), adjective = "")
227
229
  count = min == max ? min.to_s : "#{min}-#{max}"
228
- "#{count} #{adjective}#{max == 1 ? 'sentence' : 'sentences'}"
230
+ "#{count} #{adjective}#{max == 1 ? "sentence" : "sentences"}"
229
231
  end
230
232
 
231
233
  # The tail of +body+ when it is over MAX_NEW_CHARS, from a paragraph
@@ -243,7 +245,7 @@ module Samagotchi
243
245
  def tools_used(names)
244
246
  return "" if names.empty?
245
247
 
246
- " (#{names.tally.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(', ')})"
248
+ " (#{names.tally.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(", ")})"
247
249
  end
248
250
  end
249
251
 
@@ -395,6 +397,17 @@ module Samagotchi
395
397
  @engine.activity_seq > @last_fire_activity_seq
396
398
  end
397
399
 
400
+ # SHA1 of one message's role and text: detects a rewrite a count alone
401
+ # misses. Model text is taken without its thinking, which is dropped from
402
+ # older model messages when the next turn starts.
403
+ def self.digest(message)
404
+ return nil unless message.is_a?(Hash)
405
+
406
+ text = message["content"].to_s
407
+ text = TranscriptFilter.strip_thought(text) if %w[model assistant].include?(message["role"])
408
+ Digest::SHA1.hexdigest("#{message["role"]}\0#{text}")
409
+ end
410
+
398
411
  private
399
412
 
400
413
  # A failing check writes the recap without the offer line.
@@ -431,9 +444,11 @@ module Samagotchi
431
444
  # Nothing new said (only notes, tool traffic, or no messages at all):
432
445
  # the recap still stands, so no request.
433
446
  return :nothing_new if transcript.strip.empty?
447
+
434
448
  prompt = RecapPrompt.build(transcript, tool_names: TranscriptFilter.tool_names(fresh), previous: previous&.dig(:text),
435
449
  sentences: @sentences, offer: offer_open?)
436
450
  return :nothing_new if prompt.nil?
451
+
437
452
  asked = target
438
453
  @in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
439
454
  covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked[:label] }
@@ -459,6 +474,7 @@ module Samagotchi
459
474
  @in_flight = nil
460
475
  recap = safe_value(job[:thread])
461
476
  return if recap.nil? || recap.to_s.strip.empty?
477
+
462
478
  # An IdleClient::Summary names the model that answered; a plain String
463
479
  # (a stubbed client) doesn't, and the asked label stands in.
464
480
  served = recap.model if recap.respond_to?(:model)
@@ -503,17 +519,6 @@ module Samagotchi
503
519
  saved
504
520
  end
505
521
 
506
- # SHA1 of one message's role and text: detects a rewrite a count alone
507
- # misses. Model text is taken without its thinking, which is dropped from
508
- # older model messages when the next turn starts.
509
- def self.digest(message)
510
- return nil unless message.is_a?(Hash)
511
-
512
- text = message["content"].to_s
513
- text = TranscriptFilter.strip_thought(text) if %w[model assistant].include?(message["role"])
514
- Digest::SHA1.hexdigest("#{message['role']}\0#{text}")
515
- end
516
-
517
522
  def bump_generation
518
523
  @mutex.synchronize { @generation += 1 }
519
524
  end