samagotchi 0.23.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/cli.md +6 -1
  4. data/docs/configuration.md +9 -0
  5. data/docs/hooks.md +20 -6
  6. data/docs/plugins.md +1 -1
  7. data/docs/sub-agent.md +4 -3
  8. data/lib/samagotchi/bridge/card_store.rb +4 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  10. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  11. data/lib/samagotchi/bridge.rb +7 -0
  12. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/config.rb +4 -0
  16. data/lib/samagotchi/engine.rb +80 -1
  17. data/lib/samagotchi/generation_phase.rb +83 -0
  18. data/lib/samagotchi/guardrails/shell_lex.rb +111 -3
  19. data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
  20. data/lib/samagotchi/idle_recap.rb +4 -2
  21. data/lib/samagotchi/kernel_loop.rb +19 -9
  22. data/lib/samagotchi/llm/chat_loop.rb +20 -10
  23. data/lib/samagotchi/llm/openai_chat.rb +3 -0
  24. data/lib/samagotchi/log_subscriber.rb +3 -2
  25. data/lib/samagotchi/prompt.rb +4 -1
  26. data/lib/samagotchi/steer.rb +103 -8
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  29. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  30. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  31. data/lib/samagotchi/terminal_ui.rb +5 -0
  32. data/lib/samagotchi/thought_stream_splitter.rb +9 -3
  33. data/lib/samagotchi/tool_runner.rb +7 -0
  34. data/lib/samagotchi/tool_view.rb +40 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/waiting_steer.rb +64 -0
  37. data/lib/samagotchi/web/message_parts.rb +5 -0
  38. data/lib/samagotchi/web/public/activity.js +5 -0
  39. data/lib/samagotchi/web/public/app.js +2 -1
  40. data/lib/samagotchi/web/public/command_view.js +28 -0
  41. data/lib/samagotchi/web/public/copy.js +7 -0
  42. data/lib/samagotchi/web/public/index.html +8 -0
  43. data/lib/samagotchi/web/public/stage_model.js +11 -4
  44. data/lib/samagotchi/web/public/stage_view.js +5 -2
  45. data/lib/samagotchi/web/public/turn_events.js +15 -1
  46. data/lib/samagotchi/web/public/turn_view.js +15 -6
  47. data/lib/samagotchi/worker.rb +4 -1
  48. metadata +5 -1
@@ -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,8 +150,12 @@ 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
160
  add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.end_with?(">")
146
161
  end
@@ -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
@@ -114,7 +114,7 @@ module Samagotchi
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)
@@ -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.
@@ -355,8 +355,12 @@ module Samagotchi
355
355
  generation.streamed_thinking += split[:thinking].to_s.length
356
356
  return unless turn.on_stream_event
357
357
 
358
- emit(turn, type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
359
- thinking: split[:thinking], payload: chunk[:payload])
358
+ event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
359
+ thinking: split[:thinking], payload: chunk[:payload] }
360
+ # A chunk of a tool call (its bytes are dropped from both lanes): a
361
+ # steer doesn't cut this generation (Engine#cut_for_steer).
362
+ event[:tool_call] = true if split[:tool]
363
+ emit(turn, **event)
360
364
  end
361
365
 
362
366
  # The cut stream's visible text goes with it: the buffer as it was
@@ -386,18 +390,24 @@ module Samagotchi
386
390
  messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
387
391
  end
388
392
 
389
- # A generation a plugin cut is an empty answer made early: asked again
390
- # with its own nudge while the retry budget lasts (queued input goes in
391
- # place of the nudge), else the turn ends as cancelled (hook), with
392
- # nothing salvaged and without the spent nudge. A Stop that came right
393
- # after the cut is a plain cancel. Returns :next or the turn's result.
393
+ # A generation a plugin cut is an empty answer made early. Queued input
394
+ # (a user's line, a plugin's steer) goes in first, with or without a
395
+ # retry left, and spends no attempt: the model answers it. A steer's cut
396
+ # (Engine#cut_for_steer) with nothing queued (its message was taken at
397
+ # a boundary already) is asked again as is. Else it is asked again with
398
+ # its own nudge while the retry budget lasts, else the turn ends as
399
+ # cancelled (hook), with nothing salvaged and without the spent nudge.
400
+ # A Stop that came right after the cut is a plain cancel. Returns :next
401
+ # or the turn's result.
394
402
  def after_cut(turn, generation)
395
403
  cut = generation.cut
396
404
  raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller.cancelled?
397
405
 
398
- if turn.empty_retry.left?
399
- return :next if inject_pending_input!(turn)
406
+ # The row under the cut step, above the message it was cut for.
407
+ emit(turn, type: :steer_cut, iteration: turn.iteration, source: cut[:source].to_s) if cut[:steer]
408
+ return :next if inject_pending_input!(turn) || cut[:steer]
400
409
 
410
+ if turn.empty_retry.left?
401
411
  turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
402
412
  emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
403
413
  thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
@@ -107,7 +107,9 @@ module Samagotchi
107
107
  when "system"
108
108
  wire << { role: "system", content: entry[:content].to_s }
109
109
  when "user"
110
- wire << { role: "user", content: parts(entry[:content], items) }
110
+ # A steer or merged input (always a String) goes with its header.
111
+ content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
112
+ wire << { role: "user", content: parts(content, items) }
111
113
  when "model"
112
114
  wire << assistant_message(entry, paired)
113
115
  when "tool_response"
@@ -381,17 +383,22 @@ module Samagotchi
381
383
  end
382
384
 
383
385
  # A generation a plugin cut (stop_generation) is an empty answer made
384
- # early: asked again with its own nudge while the retry budget lasts
385
- # (queued input goes in place of the nudge), else the turn ends as
386
- # cancelled (hook), with nothing salvaged and without a spent nudge.
387
- # A Stop that came right after the cut is a plain cancel. Returns
388
- # :retry, or the turn's result.
386
+ # early. Queued input (a user's line, a plugin's steer) goes in
387
+ # first, with or without a retry left, and spends no attempt. A
388
+ # steer's cut (Engine#cut_for_steer) with nothing queued is asked
389
+ # again as is. Else it is asked again with its own nudge while the
390
+ # retry budget lasts, else the turn ends as cancelled (hook), with
391
+ # nothing salvaged and without a spent nudge. A Stop that came right
392
+ # after the cut is a plain cancel. Returns :retry, or the turn's
393
+ # result.
389
394
  def after_cut(iteration, response)
390
395
  return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
391
396
 
392
- if @empty_retry.left?
393
- return :retry if inject_pending_input(iteration)
397
+ # The row under the cut step, above the message it was cut for.
398
+ emit(type: :steer_cut, iteration: iteration, source: response.cut[:source].to_s) if response.cut[:steer]
399
+ return :retry if inject_pending_input(iteration) || response.cut[:steer]
394
400
 
401
+ if @empty_retry.left?
395
402
  @empty_retry.nudge!(@conversation, TurnNote.cut_retry(response.cut[:by], response.cut[:reason]),
396
403
  emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
397
404
  thinking_chars: response.reasoning.to_s.length, stopped_by: response.cut[:by])
@@ -478,8 +485,11 @@ module Samagotchi
478
485
  on_delta: lambda { |content:, reasoning:, payload:|
479
486
  streamed << content
480
487
  thought << reasoning
481
- emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
482
- thinking: reasoning, payload: payload)
488
+ event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
489
+ thinking: reasoning, payload: payload }
490
+ # A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
491
+ event[:tool_call] = true if OpenAIChat.tool_call_delta?(payload)
492
+ emit(event)
483
493
  },
484
494
  on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
485
495
  )
@@ -69,6 +69,9 @@ module Samagotchi
69
69
  **)
70
70
  end
71
71
 
72
+ # true when a streamed +payload+ carries a tool-call delta.
73
+ def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
74
+
72
75
  # true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
73
76
  def self.openrouter?(url)
74
77
  host = URI(url.to_s).host.to_s.downcase
@@ -109,8 +109,9 @@ module Samagotchi
109
109
  stopped_by: event[:stopped_by])
110
110
  return unless event[:stopped_by]
111
111
 
112
- # A plugin cut this generation (stop_generation); the turn goes on.
113
- log(:info, :generation_stopped, iteration: event[:iteration], bundle: event[:stopped_by],
112
+ # A plugin (stop_generation) or a steer (Engine#cut_for_steer, by
113
+ # "steer") cut this generation; the turn goes on.
114
+ log(:info, :generation_stopped, iteration: event[:iteration], by: event[:stopped_by],
114
115
  reason: event[:stop_reason].to_s[0, 200], thinking_chars: event[:thinking_chars], ms: ms)
115
116
  end
116
117
 
@@ -4,6 +4,7 @@ require_relative "model_profile"
4
4
  require_relative "prompt_literal_guard"
5
5
  require_relative "vision_context"
6
6
  require_relative "tool_response"
7
+ require_relative "steer"
7
8
 
8
9
  module Samagotchi
9
10
  # Formats a message list into a prompt string ready for the /completion endpoint.
@@ -168,7 +169,9 @@ module Samagotchi
168
169
  # peer session), and a turn note carries an error's text: escaped like
169
170
  # user content so it can't close its turn.
170
171
  role = ESCAPED_KINDS.include?(message[:kind].to_s) ? "user" : message[:role]
171
- PromptLiteralGuard.escape(message[:content], profile: profile, role: role)
172
+ # A steer or merged input is led by a header naming its sender (added
173
+ # after the escape: the header has no literals to guard).
174
+ Steer.wire_text(message, PromptLiteralGuard.escape(message[:content], profile: profile, role: role))
172
175
  end
173
176
 
174
177
  private_class_method :image_lines, :format_with_turn_markers, :ends_in_tool_response?, :tool_response_blocks,
@@ -10,20 +10,41 @@ module Samagotchi
10
10
  #
11
11
  # The user's own lines merged into a running turn are marked kind:
12
12
  # "input": they are part of that turn, not a turn of their own, so a
13
- # reloaded history keeps the turn in one piece.
13
+ # reloaded history keeps the turn in one piece. A line can be a Line
14
+ # naming who sent it (a worker's input from chi send, a delegating parent,
15
+ # a plugin); its input message then carries that source:. The user's own
16
+ # lines carry none, so their saved bytes are as they always were.
14
17
  module Steer
15
18
  KIND = "steer"
16
19
  INPUT_KIND = "input"
17
20
 
21
+ # A drain item for an input line with its sender (source nil = the user).
22
+ # A plain String is still a valid item: the user's line.
23
+ Line = Data.define(:text, :source)
24
+
25
+ # The client ids whose input is not the user's own words. Literals, not
26
+ # SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
27
+ # needs no requires (a spec pins them equal).
28
+ CHI_SEND_CLIENT = "cli:send"
29
+ DELEGATE_CLIENT_PREFIX = "delegate:"
30
+ PLUGIN_CLIENT = "plugin"
31
+
18
32
  # What one drain brought: the merged user text (nil when none) and how
19
- # many lines made it, and the steers in order.
20
- Merge = Struct.new(:content, :count, :steers, keyword_init: true) do
33
+ # many lines made it, the input messages' text grouped by sender
34
+ # (consecutive lines from one sender form one group), and the steers in
35
+ # order.
36
+ Merge = Struct.new(:content, :count, :inputs, :steers, keyword_init: true) do
21
37
  def empty? = content.nil? && steers.empty?
22
38
 
23
- # The messages it appends: the user's first (marked merged input),
24
- # then each steer.
39
+ # The messages it appends: the merged input first (one message per
40
+ # sender run, marked kind input, source: when not the user's), then
41
+ # each steer.
25
42
  def messages
26
- list = content ? [{ role: "user", kind: INPUT_KIND, content: content }] : []
43
+ list = inputs.map do |input|
44
+ message = { role: "user", kind: INPUT_KIND }
45
+ message[:source] = input[:source] if input[:source]
46
+ message.merge(content: input[:content])
47
+ end
27
48
  list + steers.map { |steer| Steer.message(**steer) }
28
49
  end
29
50
 
@@ -36,8 +57,50 @@ module Samagotchi
36
57
  end
37
58
  end
38
59
 
60
+ # The one line the model reads above a steer or merged input (the wire
61
+ # only; the saved content stays raw): who sent it, and that it should
62
+ # follow it or, if it asks for nothing, carry on.
63
+ HEADER_TAIL = "mid-task. Follow it; if it asks for nothing, carry on with the task.]"
64
+ SENDERS = {
65
+ "user" => "from the user",
66
+ "parent_agent" => "from the parent agent that started this session",
67
+ "chi_send" => "sent with chi send",
68
+ "plugin_send" => "sent by a plugin"
69
+ }.freeze
70
+ USER_HEADER = "[Steer #{SENDERS["user"]}, #{HEADER_TAIL}".freeze
71
+
39
72
  module_function
40
73
 
74
+ # The saved source of a worker input line from +client_id+: nil for the
75
+ # user (no client id, the web, an attached TUI, anything unknown).
76
+ def source_for_client(client_id)
77
+ id = client_id.to_s
78
+ if id == CHI_SEND_CLIENT then "chi_send"
79
+ elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
80
+ elsif id == PLUGIN_CLIENT then "plugin_send"
81
+ end
82
+ end
83
+
84
+ # The header for a steer or merged input message, else nil. No source
85
+ # (or "") is the user's; an input with an unknown source too; a steer
86
+ # with an unknown source is a plugin's (its bundle or hook label).
87
+ def header(message)
88
+ kind = (message[:kind] || message["kind"]).to_s
89
+ return unless [KIND, INPUT_KIND].include?(kind)
90
+
91
+ source = (message[:source] || message["source"]).to_s
92
+ source = "user" if source.empty?
93
+ sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
94
+ "[Steer #{sender}, #{HEADER_TAIL}"
95
+ end
96
+
97
+ # The text a steer or merged input goes out as: its header, a newline,
98
+ # then +text+ (the message's content by default). Other messages: +text+.
99
+ def wire_text(message, text = (message[:content] || message["content"]).to_s)
100
+ header = header(message)
101
+ header ? "#{header}\n#{text}" : text
102
+ end
103
+
41
104
  def message(text:, source:)
42
105
  { role: "user", kind: KIND, source: source.to_s, content: text.to_s }
43
106
  end
@@ -46,6 +109,24 @@ module Samagotchi
46
109
  (message[:kind] || message["kind"]).to_s == KIND
47
110
  end
48
111
 
112
+ # The sources of a steer a person sent (a continue card's text, from the
113
+ # user or a parent agent's chi answer), not a plugin's prod.
114
+ PERSON_SOURCES = %w[user parent_agent].freeze
115
+
116
+ # The senders whose message may cut a generation that has only been
117
+ # thinking (Engine#cut_for_steer): the user (no source), chi send, a
118
+ # parent agent. A plugin's never does.
119
+ CUT_SOURCES = ["", "user", "chi_send", "parent_agent"].freeze
120
+
121
+ def cuts?(source)
122
+ CUT_SOURCES.include?(source.to_s)
123
+ end
124
+
125
+ # A steer a person sent: what a recap and a side question keep.
126
+ def person?(message)
127
+ steer?(message) && PERSON_SOURCES.include?((message[:source] || message["source"]).to_s)
128
+ end
129
+
49
130
  # A user message that is a prompt or the user's steering, not a steer:
50
131
  # what "the last prompt" and "user turns" count.
51
132
  def prompt?(message)
@@ -105,8 +186,22 @@ module Samagotchi
105
186
  text = (item[:text] || item["text"]).to_s.strip
106
187
  { source: (item[:source] || item["source"]).to_s, text: text } unless text.empty?
107
188
  end
108
- content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
109
- Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length, steers: steers)
189
+ inputs = input_groups(lines)
190
+ content = inputs.map { |input| input[:content] }.join("\n\n")
191
+ Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length,
192
+ inputs: inputs, steers: steers)
193
+ end
194
+
195
+ # Non-blank input lines as [{content:, source:}], one per run of lines
196
+ # from the same sender.
197
+ def input_groups(lines)
198
+ pairs = lines.filter_map do |line|
199
+ text = (line.is_a?(Line) ? line.text : line).to_s.strip
200
+ [text, line.is_a?(Line) ? line.source : nil] unless text.empty?
201
+ end
202
+ pairs.chunk_while { |a, b| a[1] == b[1] }.map do |run|
203
+ { content: run.map(&:first).join("\n\n"), source: run.first[1] }
204
+ end
110
205
  end
111
206
  end
112
207
  end
@@ -1189,7 +1189,7 @@ module Samagotchi
1189
1189
  # hook's notice, a retry's line), as they were drawn live.
1190
1190
  # Questions are left out: the pending one is asked after the
1191
1191
  # replay, answered ones showed no line.
1192
- when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry then @renderer.call(event)
1192
+ when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut then @renderer.call(event)
1193
1193
  end
1194
1194
  end
1195
1195
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
@@ -79,6 +79,9 @@ module Samagotchi
79
79
  when :empty_answer_retry
80
80
  @view.finish_thinking_spinner
81
81
  @view.print_line(@view.format_empty_retry_line(event))
82
+ when :steer_cut
83
+ @view.finish_thinking_spinner
84
+ @view.print_line(@view.format_steer_cut_line(event))
82
85
  when :turn_completed
83
86
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
84
87
  when :turn_canceled
@@ -45,6 +45,15 @@ module Samagotchi
45
45
  paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
46
46
  end
47
47
 
48
+ # "↪ cut in for your message": a message for the running turn cut a
49
+ # generation that was only thinking (Engine#cut_for_steer); an empty
50
+ # or unknown source is the user's.
51
+ STEER_CUT_FOR = { "chi_send" => "a message sent with chi send", "parent_agent" => "the parent agent's message" }.freeze
52
+
53
+ def format_steer_cut_line(event)
54
+ paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
55
+ end
56
+
48
57
  # "no answer: the model returned nothing (after 1 retry)", dim like the
49
58
  # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
50
59
  def format_empty_answer_line(retries)
@@ -29,6 +29,7 @@ module Samagotchi
29
29
  def call(event)
30
30
  case event[:type]
31
31
  when :empty_answer_retry then line(format_empty_retry_line(event))
32
+ when :steer_cut then line(format_steer_cut_line(event))
32
33
  when :generation_retrying then line(format_generation_retry_line(event))
33
34
  when :turn_completed, :turn_canceled then @turn_over = true
34
35
  when :hook_notice
@@ -1007,7 +1007,12 @@ module Samagotchi
1007
1007
  # Steering merges text only: a line with images runs as the next turn.
1008
1008
  return image_line_waits if ImageInput.extract(line).any?
1009
1009
 
1010
+ epoch = @engine.input_epoch
1010
1011
  @pending_input_queue.push(line.strip)
1012
+ # The user's line cuts a generation that has streamed only thinking
1013
+ # for long (steer.cut_after), now or once it has: the model starts the
1014
+ # step again with it.
1015
+ @engine.cut_for_steer(nil, epoch: epoch)
1011
1016
  true
1012
1017
  end
1013
1018
 
@@ -46,12 +46,16 @@ module Samagotchi
46
46
  @active = nil
47
47
  end
48
48
 
49
- # Feed one raw chunk. Returns the newly-routed deltas for this chunk only.
50
- # @return [Hash{ text: String, thinking: String }]
49
+ # Feed one raw chunk. Returns the newly-routed deltas for this chunk
50
+ # only, and +tool+: whether the chunk opened, streamed into or closed a
51
+ # tool_call block (its bytes are dropped, but a steer must not cut a
52
+ # generation that writes a tool call: Engine#cut_for_steer).
53
+ # @return [Hash{ text: String, thinking: String, tool: Boolean }]
51
54
  def feed(chunk)
52
55
  @carry << chunk.to_s
53
56
  text = +""
54
57
  thinking = +""
58
+ tool = false
55
59
  loop do
56
60
  if @state == :normal
57
61
  hit = earliest_open
@@ -64,9 +68,11 @@ module Samagotchi
64
68
  @carry = @carry[hit[:pos]..]
65
69
  @active = hit[:block]
66
70
  @state = :in_block
71
+ tool ||= @active[:lane] == :drop
67
72
  # Drop the open marker itself; its body follows.
68
73
  @carry = @carry[@active[:open].length..].to_s
69
74
  else
75
+ tool ||= @active[:lane] == :drop && !@carry.empty?
70
76
  close_pos = @carry.index(@active[:close])
71
77
  if close_pos.nil?
72
78
  flush_in_block(thinking)
@@ -79,7 +85,7 @@ module Samagotchi
79
85
  @state = :normal
80
86
  end
81
87
  end
82
- { text: text, thinking: thinking }
88
+ { text: text, thinking: thinking, tool: tool }
83
89
  end
84
90
 
85
91
  # Flush any residual state at end of stream (e.g. an unterminated thinking
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tool_activity"
4
+ require_relative "tool_view"
4
5
  require_relative "guardrails"
5
6
  require_relative "vision_context"
6
7
  require_relative "log"
@@ -55,6 +56,9 @@ module Samagotchi
55
56
  # The worker runs in its session's working directory.
56
57
  title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
57
58
  started[:title] = title if title
59
+ # The full command for a richer UI (params stays cut at 80).
60
+ view = ToolView.for(call[:name], call)&.to_h
61
+ started[:view] = view if view
58
62
  emit(on_stream_event, started)
59
63
 
60
64
  # The ask comes after tool_call_started: the UI shows the tool line,
@@ -95,6 +99,9 @@ module Samagotchi
95
99
  completed[:images] = images if images&.any?
96
100
  completed[:diff] = diff if diff
97
101
  completed[:waited_ms] = waited_ms if waited_ms
102
+ # Also here: a UI that missed the start (a replay gap) builds its row
103
+ # from this event.
104
+ completed[:view] = view if view
98
105
  emit(on_stream_event, completed)
99
106
  # After the completed event: a hook's card (check-in, skills) prints
100
107
  # under the call's tool row, not before it.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/execute"
4
+ require_relative "tools/task_create"
5
+
6
+ module Samagotchi
7
+ # What a richer UI shows of a tool call beyond its one-line params: the
8
+ # full command of an execute / task_create (whitespace kept), and the
9
+ # call's own cwd: argument. Built by the server on every path (the live
10
+ # tool_call_started, the bridge's snapshot and replay, a reload from the
11
+ # saved call), so the UIs never parse the call themselves. params and its
12
+ # 80-char cut stay as they are (the guardrails, "ran as:", the TUI).
13
+ #
14
+ # +truncated+: the command was longer than COMMAND_LIMIT and is cut to it;
15
+ # +chars+ is then its full length.
16
+ ToolView = Data.define(:command, :cwd, :truncated, :chars) do
17
+ def to_h = super.compact.reject { |_key, value| value == false }
18
+ end
19
+
20
+ class ToolView
21
+ # The longest command a view carries (the spike's longest was 3,799).
22
+ COMMAND_LIMIT = 8_000
23
+
24
+ TOOLS = [Tools::Execute::NAME, Tools::TaskCreate::NAME].freeze
25
+
26
+ # @return [ToolView, nil] nil for a tool without a view and for an
27
+ # empty command
28
+ def self.for(tool_name, call)
29
+ return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
30
+
31
+ command = call[:content].to_s
32
+ return nil if command.strip.empty?
33
+
34
+ cwd = call[:cwd].to_s.strip
35
+ truncated = command.length > COMMAND_LIMIT
36
+ new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
37
+ truncated: truncated, chars: truncated ? command.length : nil)
38
+ end
39
+ end
40
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.23.0"
4
+ VERSION = "0.24.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end