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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/docs/cli.md +6 -1
- data/docs/configuration.md +9 -0
- data/docs/hooks.md +20 -6
- data/docs/plugins.md +1 -1
- data/docs/sub-agent.md +4 -3
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +7 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +80 -1
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +111 -3
- data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
- data/lib/samagotchi/idle_recap.rb +4 -2
- data/lib/samagotchi/kernel_loop.rb +19 -9
- data/lib/samagotchi/llm/chat_loop.rb +20 -10
- data/lib/samagotchi/llm/openai_chat.rb +3 -0
- data/lib/samagotchi/log_subscriber.rb +3 -2
- data/lib/samagotchi/prompt.rb +4 -1
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thought_stream_splitter.rb +9 -3
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +40 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +28 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/index.html +8 -0
- data/lib/samagotchi/web/public/stage_model.js +11 -4
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/worker.rb +4 -1
- 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.
|
|
9
|
-
#
|
|
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, &>, <,
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
|
113
|
-
|
|
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
|
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -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,
|
|
20
|
-
|
|
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
|
|
24
|
-
#
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
50
|
-
#
|
|
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
|