samagotchi 0.50.0 → 0.52.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 +89 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -11
- data/docs/configuration.md +63 -21
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +10 -5
- data/docs/testing.md +6 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +29 -17
- data/lib/samagotchi/bridge_client.rb +3 -1
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +56 -0
- data/lib/samagotchi/engine.rb +48 -17
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +21 -13
- data/lib/samagotchi/kernel_loop.rb +28 -2
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +92 -21
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +41 -3
- data/lib/samagotchi/session_inbox.rb +36 -7
- data/lib/samagotchi/session_manager.rb +21 -10
- data/lib/samagotchi/session_metrics.rb +25 -6
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/steer_cut.rb +21 -10
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
- data/lib/samagotchi/terminal_ui.rb +56 -8
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_runner.rb +2 -0
- data/lib/samagotchi/tools/builtin_calls.rb +33 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +59 -7
- data/lib/samagotchi/web/message_parts.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +6 -0
- data/lib/samagotchi/web/public/app.js +199 -48
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +25 -20
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/session_title.js +26 -0
- data/lib/samagotchi/web/public/strip.js +4 -6
- data/lib/samagotchi/web/public/thinking_chip.js +169 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +26 -2
- data/lib/samagotchi/web/public/turn_html.js +3 -1
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +44 -43
- data/lib/samagotchi/worker_inbound.rb +10 -7
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- data/lib/samagotchi.rb +1 -0
- metadata +13 -1
|
@@ -11,6 +11,7 @@ require_relative "tools/context_read"
|
|
|
11
11
|
require_relative "tools/peers"
|
|
12
12
|
require_relative "commands/registry"
|
|
13
13
|
require_relative "llm_context_command"
|
|
14
|
+
require_relative "thinking_command"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# The session commands a REPL and a session worker both run: /model,
|
|
@@ -29,6 +30,7 @@ module Samagotchi
|
|
|
29
30
|
CONTEXT_COMMAND = "/context"
|
|
30
31
|
HELP_COMMAND = "/help"
|
|
31
32
|
LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
|
|
33
|
+
THINKING_COMMAND = ThinkingCommand::NAME
|
|
32
34
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
33
35
|
# shows this many per host; /models <text> lists every match.
|
|
34
36
|
MODELS_PER_HOST = 20
|
|
@@ -88,6 +90,8 @@ module Samagotchi
|
|
|
88
90
|
# Setting it saves the session; it takes effect at the next turn's start.
|
|
89
91
|
registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
|
|
90
92
|
id: :llm_context, mid_turn: show_mid_turn(LLM_CONTEXT_COMMAND), match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
|
|
93
|
+
registry.register(THINKING_COMMAND, "show or set this session's thinking level (off|low|medium|high|default)",
|
|
94
|
+
id: :thinking, mid_turn: show_mid_turn(THINKING_COMMAND), match: ->(text) { text.match?(%r{\A/thinking(?:\s+.*)?\z}) }) { |text| thinking(text) }
|
|
91
95
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
92
96
|
registry.register("/stats", "show the session's stats", local: true)
|
|
93
97
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -100,6 +104,16 @@ module Samagotchi
|
|
|
100
104
|
# The REPL owns its session: it answers /detach with a note, and doesn't offer it.
|
|
101
105
|
registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
|
|
102
106
|
match: ->(text) { text.casecmp?("/detach") })
|
|
107
|
+
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
108
|
+
# themselves (Formatting#steer_choice) before any command check; the
|
|
109
|
+
# entries exist for Tab completion and /help. The web composer sends
|
|
110
|
+
# /cut too. /queue is a real after-the-turn list only in the local
|
|
111
|
+
# REPL (the attached TUI's worker doesn't skip queued files yet), so
|
|
112
|
+
# it is offered there.
|
|
113
|
+
registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
|
|
114
|
+
local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
115
|
+
registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
|
|
116
|
+
local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
|
|
103
117
|
registry
|
|
104
118
|
end
|
|
105
119
|
|
|
@@ -166,13 +180,26 @@ module Samagotchi
|
|
|
166
180
|
notes = []
|
|
167
181
|
notes << entry.source unless entry.source == "core"
|
|
168
182
|
notes << MID_TURN_NOTES[entry.mid_turn_label] if MID_TURN_NOTES[entry.mid_turn_label]
|
|
169
|
-
notes <<
|
|
183
|
+
notes << "#{ui_words(entry.uis)} only" if entry.local
|
|
170
184
|
line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
|
|
171
185
|
notes.empty? ? line : "#{line} (#{notes.join("; ")})"
|
|
172
186
|
end
|
|
173
187
|
"commands:\n#{lines.join("\n")}"
|
|
174
188
|
end
|
|
175
189
|
|
|
190
|
+
# Where a UI's own entry runs, for /help: both terminals are
|
|
191
|
+
# "terminal" (also for no uis: every terminal), then "web".
|
|
192
|
+
# @param uis [Array<Symbol>, nil]
|
|
193
|
+
# @return [String] "terminal", "repl", "terminal and web" …
|
|
194
|
+
def ui_words(uis)
|
|
195
|
+
return "terminal" if uis.nil?
|
|
196
|
+
|
|
197
|
+
terminals = uis & %i[repl attached]
|
|
198
|
+
words = terminals.size == 2 ? ["terminal"] : terminals.map(&:to_s)
|
|
199
|
+
words << "web" if uis.include?(:web)
|
|
200
|
+
words.join(" and ")
|
|
201
|
+
end
|
|
202
|
+
|
|
176
203
|
# How help and errors name an entry (the shell's "!" is "!<cmd>").
|
|
177
204
|
def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
|
|
178
205
|
|
|
@@ -363,14 +390,25 @@ module Samagotchi
|
|
|
363
390
|
# /llm-context (LLMContextCommand); :llm_context in changed when it set
|
|
364
391
|
# the session's values.
|
|
365
392
|
def llm_context(text)
|
|
366
|
-
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) {
|
|
393
|
+
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
367
394
|
.run(text.delete_prefix(LLM_CONTEXT_COMMAND))
|
|
368
395
|
reply(output, changed: changed ? [:llm_context] : [])
|
|
369
396
|
rescue ArgumentError => e
|
|
370
397
|
reply(e.message, status: :error)
|
|
371
398
|
end
|
|
372
399
|
|
|
373
|
-
|
|
400
|
+
# /thinking (ThinkingCommand); :thinking in changed when it set the
|
|
401
|
+
# session's level.
|
|
402
|
+
def thinking(text)
|
|
403
|
+
output, changed = ThinkingCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
404
|
+
.run(text.delete_prefix(THINKING_COMMAND))
|
|
405
|
+
reply(output, changed: changed ? [:thinking] : [])
|
|
406
|
+
rescue ArgumentError => e
|
|
407
|
+
reply(e.message, status: :error)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# A session setting changed (/llm-context, /thinking): saved with its model.
|
|
411
|
+
def save_setup(session)
|
|
374
412
|
@engine.store_model!(session)
|
|
375
413
|
@save.call(session)
|
|
376
414
|
end
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
|
+
require_relative "delivery"
|
|
8
9
|
require_relative "session"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -34,12 +35,18 @@ module Samagotchi
|
|
|
34
35
|
class NoteRejected < ArgumentError; end
|
|
35
36
|
|
|
36
37
|
# Queue a turn's input file: JSON with the prompt and the sender's ids.
|
|
38
|
+
# +delivery+ is only written when it is "queue": a step-boundary
|
|
39
|
+
# message is the default an older worker assumes, and a cut is done by
|
|
40
|
+
# the sender (the Bridge) at once, not by the file.
|
|
37
41
|
# @param client_id [String, nil] the sending UI
|
|
38
42
|
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
43
|
+
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
44
|
+
# "cut" or "queue"
|
|
39
45
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
40
46
|
# session's images/
|
|
41
47
|
# @return [String, false] the input file's path, or false
|
|
42
|
-
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: []
|
|
48
|
+
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
|
|
49
|
+
delivery: nil)
|
|
43
50
|
images = Array(images)
|
|
44
51
|
input_dir = File.join(session_dir, INPUT_DIR)
|
|
45
52
|
FileUtils.mkdir_p(input_dir)
|
|
@@ -47,6 +54,7 @@ module Samagotchi
|
|
|
47
54
|
path = File.join(input_dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}.json")
|
|
48
55
|
record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
|
|
49
56
|
"no_interrupt" => (no_interrupt ? true : nil),
|
|
57
|
+
"delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
|
|
50
58
|
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
|
|
51
59
|
AtomicFile.write(path, JSON.generate(record))
|
|
52
60
|
path
|
|
@@ -175,17 +183,29 @@ module Samagotchi
|
|
|
175
183
|
Dir.glob(File.join(input_dir, "*.json"))
|
|
176
184
|
end
|
|
177
185
|
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
186
|
+
# A claimed input file as read: its +prompt+, +origin+ ({client_id:,
|
|
187
|
+
# enqueued_id:}, nil when it names no sender), whether its turn runs
|
|
188
|
+
# with the raised iteration limit (+no_interrupt+, --no-interrupt), its
|
|
189
|
+
# +images+ ({file:, name:} refs), and its +delivery+ ("queue" or nil; a
|
|
190
|
+
# file without the key is one an older chi wrote, so nil).
|
|
191
|
+
Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
|
|
192
|
+
def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
|
|
193
|
+
end
|
|
194
|
+
# A file that doesn't parse, or isn't a JSON object: no prompt.
|
|
195
|
+
UNREADABLE_INPUT = Input.new(prompt: nil)
|
|
196
|
+
|
|
197
|
+
# @return [Input] a claimed input file's contents; UNREADABLE_INPUT for
|
|
198
|
+
# one that isn't a JSON object
|
|
182
199
|
def self.read_input(claimed_file)
|
|
183
200
|
data = JSON.parse(File.read(claimed_file).to_s)
|
|
201
|
+
return UNREADABLE_INPUT unless data.is_a?(Hash)
|
|
202
|
+
|
|
184
203
|
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
|
|
185
204
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
186
|
-
|
|
205
|
+
Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
|
|
206
|
+
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
|
|
187
207
|
rescue JSON::ParserError
|
|
188
|
-
|
|
208
|
+
UNREADABLE_INPUT
|
|
189
209
|
end
|
|
190
210
|
|
|
191
211
|
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
@@ -197,6 +217,15 @@ module Samagotchi
|
|
|
197
217
|
false
|
|
198
218
|
end
|
|
199
219
|
|
|
220
|
+
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
221
|
+
# of its own instead of merging it at a step boundary. Today that is an
|
|
222
|
+
# image message (steering merges text only); the drain's file filter
|
|
223
|
+
# reads this one predicate, so what a boundary refuses changes in one
|
|
224
|
+
# place.
|
|
225
|
+
def self.waits_for_turn_end?(input_file)
|
|
226
|
+
input_has_images?(input_file)
|
|
227
|
+
end
|
|
228
|
+
|
|
200
229
|
def self.claim_input_file(input_file)
|
|
201
230
|
processing_path = "#{input_file}.processing"
|
|
202
231
|
File.rename(input_file, processing_path)
|
|
@@ -8,11 +8,13 @@ require "rbconfig"
|
|
|
8
8
|
require_relative "client_id"
|
|
9
9
|
require_relative "atomic_file"
|
|
10
10
|
require_relative "config"
|
|
11
|
+
require_relative "delivery"
|
|
11
12
|
|
|
12
13
|
require_relative "session"
|
|
13
14
|
require_relative "session_inbox"
|
|
14
15
|
require_relative "session_metrics"
|
|
15
16
|
require_relative "llm_context_strategy"
|
|
17
|
+
require_relative "session_setup"
|
|
16
18
|
require_relative "host_registry"
|
|
17
19
|
require_relative "utf8_default"
|
|
18
20
|
require_relative "turn_note"
|
|
@@ -162,8 +164,8 @@ module Samagotchi
|
|
|
162
164
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
163
165
|
# @param title [String, nil] what the lists show before the first turn
|
|
164
166
|
# (the prompt's preview by default, else the seed's first user message)
|
|
165
|
-
# @param
|
|
166
|
-
#
|
|
167
|
+
# @param setup [SessionSetup] the session's own settings (chi
|
|
168
|
+
# --llm-context; a fork and a continue copy their session's)
|
|
167
169
|
# @param continues [String, nil] the session this one continues (a
|
|
168
170
|
# session field; #continue_session)
|
|
169
171
|
# @param note [String, nil] a context note the session starts with,
|
|
@@ -175,7 +177,7 @@ module Samagotchi
|
|
|
175
177
|
# and #model_warning: an id its host's saved list doesn't have
|
|
176
178
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
177
179
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
178
|
-
title: nil, delegate: false,
|
|
180
|
+
title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
179
181
|
note_source: "chi", before_spawn: nil)
|
|
180
182
|
sd = state_dir || Session.default_state_dir
|
|
181
183
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
@@ -197,7 +199,7 @@ module Samagotchi
|
|
|
197
199
|
parent_id: parent_id,
|
|
198
200
|
messages: messages,
|
|
199
201
|
delegate: delegate,
|
|
200
|
-
|
|
202
|
+
setup: setup,
|
|
201
203
|
continues: continues
|
|
202
204
|
)
|
|
203
205
|
session.model_warning = model_warning
|
|
@@ -231,7 +233,7 @@ module Samagotchi
|
|
|
231
233
|
|
|
232
234
|
# Start the next link of a chain: a new session that continues
|
|
233
235
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
234
|
-
# it was typed as, so an alias holds) and with its own
|
|
236
|
+
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
235
237
|
# starting with a context note from chi: the link and the previous
|
|
236
238
|
# link's recap (SessionChain.note_text). The previous link is archived
|
|
237
239
|
# first, with its finished delegates (archive_session's rules: a turn
|
|
@@ -362,7 +364,7 @@ module Samagotchi
|
|
|
362
364
|
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
363
365
|
end
|
|
364
366
|
link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
|
|
365
|
-
model_name: previous.model_typed || previous.model_name,
|
|
367
|
+
model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
|
|
366
368
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
367
369
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
|
|
368
370
|
link.moved_children = move
|
|
@@ -514,7 +516,7 @@ module Samagotchi
|
|
|
514
516
|
|
|
515
517
|
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
516
518
|
# Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
|
|
517
|
-
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
519
|
+
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
|
|
518
520
|
|
|
519
521
|
# List all sessions, reading status from persisted session.json files.
|
|
520
522
|
# +project_root+: only that project's sessions (nil: every session).
|
|
@@ -1288,14 +1290,16 @@ module Samagotchi
|
|
|
1288
1290
|
# boundary. The file is JSON carrying the sender's ids.
|
|
1289
1291
|
# @param client_id [String, nil] the sending UI
|
|
1290
1292
|
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
1293
|
+
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
1294
|
+
# "cut" or "queue"
|
|
1291
1295
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
1292
1296
|
# session's images/
|
|
1293
1297
|
# @return [String, false] the input file's path, or false.
|
|
1294
1298
|
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
1295
|
-
images: [])
|
|
1299
|
+
images: [], delivery: nil)
|
|
1296
1300
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1297
1301
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1298
|
-
no_interrupt: no_interrupt, images: images)
|
|
1302
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery)
|
|
1299
1303
|
end
|
|
1300
1304
|
|
|
1301
1305
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
@@ -1308,6 +1312,8 @@ module Samagotchi
|
|
|
1308
1312
|
# exit). Race-safe against a TUI taking the session, or the worker
|
|
1309
1313
|
# exiting, between the resume and the write.
|
|
1310
1314
|
# @param images [Array<Hash>] refs ({file:, name:}) already in images/
|
|
1315
|
+
# @param delivery [String, nil] what the message asks the running turn
|
|
1316
|
+
# for: "next_step" (default), "cut" or "queue"
|
|
1311
1317
|
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1312
1318
|
# stand-in (the web's specs); #session_owner is optional
|
|
1313
1319
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
@@ -1319,7 +1325,8 @@ module Samagotchi
|
|
|
1319
1325
|
# {status: :failed} when the input file couldn't be written
|
|
1320
1326
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1321
1327
|
# @raise [ArgumentError] no such session
|
|
1322
|
-
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self,
|
|
1328
|
+
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
|
|
1329
|
+
bridge: nil)
|
|
1323
1330
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1324
1331
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1325
1332
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
@@ -1327,6 +1334,9 @@ module Samagotchi
|
|
|
1327
1334
|
begin
|
|
1328
1335
|
options = { prompt: prompt, client_id: client_id }
|
|
1329
1336
|
options[:images] = images unless images.empty?
|
|
1337
|
+
# An old worker ignores the field: it cuts anyway (its own default)
|
|
1338
|
+
# and its file never carries a queue, so a queue would steer there.
|
|
1339
|
+
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1330
1340
|
reply = client.post_turn(**options)
|
|
1331
1341
|
ack = reply.json
|
|
1332
1342
|
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
@@ -1347,6 +1357,7 @@ module Samagotchi
|
|
|
1347
1357
|
enqueued_id = SecureRandom.uuid
|
|
1348
1358
|
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
1349
1359
|
input[:images] = images unless images.empty?
|
|
1360
|
+
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1350
1361
|
path = manager.write_turn_input(session_id, **input)
|
|
1351
1362
|
return { status: :failed } unless path
|
|
1352
1363
|
|
|
@@ -379,6 +379,16 @@ module Samagotchi
|
|
|
379
379
|
@mutex.synchronize { build_snapshot(:recent) }
|
|
380
380
|
end
|
|
381
381
|
|
|
382
|
+
# The snapshot of a session no worker runs, read from disk (`chi
|
|
383
|
+
# sessions stats`): #snapshot's, but its records are the newest saved
|
|
384
|
+
# turn's (and its tool calls), as a live worker's snapshot carries its
|
|
385
|
+
# newest finished turn; #snapshot would list none, this collector having
|
|
386
|
+
# finished no turn of its own.
|
|
387
|
+
# @return [Hash]
|
|
388
|
+
def saved_snapshot
|
|
389
|
+
@mutex.synchronize { build_snapshot(:last) }
|
|
390
|
+
end
|
|
391
|
+
|
|
382
392
|
# Close the open generation now, ahead of its :generation_completed
|
|
383
393
|
# (which then finds it closed), and report its speed with the session's
|
|
384
394
|
# running token totals: what the event carries to the UIs.
|
|
@@ -441,6 +451,9 @@ module Samagotchi
|
|
|
441
451
|
served_model_for: @served_model_for,
|
|
442
452
|
tool_calls_total: @tool_records.size + in_flight.size,
|
|
443
453
|
tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
|
|
454
|
+
# The calls the model made under an alias's name (bash run as
|
|
455
|
+
# execute), by its own spelling; only when there are any.
|
|
456
|
+
**(totals[:aliased].empty? ? {} : { tool_calls_aliased: totals[:aliased].dup }),
|
|
444
457
|
tool_errors: totals[:tool_errors],
|
|
445
458
|
iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
|
|
446
459
|
gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
|
|
@@ -486,14 +499,16 @@ module Samagotchi
|
|
|
486
499
|
|
|
487
500
|
# The records a snapshot carries, copied. :all is every record; :recent
|
|
488
501
|
# the newest turn this collector finished (live before its save lands)
|
|
489
|
-
# and the unsaved ones (capped)
|
|
490
|
-
#
|
|
491
|
-
# from the tails. Caller holds
|
|
502
|
+
# and the unsaved ones (capped); :last the newest turn, loaded or not;
|
|
503
|
+
# and the tool records of those turns and of the running one. Records
|
|
504
|
+
# are appended in turn order, so both come from the tails. Caller holds
|
|
505
|
+
# the mutex.
|
|
492
506
|
def records_view(mode)
|
|
493
507
|
return { turn_records: @turn_records.map(&:dup), tool_records: @tool_records.map(&:dup) } if mode == :all
|
|
494
508
|
|
|
495
509
|
unsaved = @turn_records.size - @persisted_turns
|
|
496
|
-
|
|
510
|
+
fresh = @turn_records.size > @loaded_turns ? 1 : 0
|
|
511
|
+
count = mode == :last ? 1 : unsaved.clamp(fresh, LIVE_RECORDS_CAP)
|
|
497
512
|
turns = @turn_records.last(count)
|
|
498
513
|
ids = turns.to_set { |record| record[:id] }
|
|
499
514
|
ids << @turn.id if @turn
|
|
@@ -807,6 +822,8 @@ module Samagotchi
|
|
|
807
822
|
active = @turn.tool_calls_by_id.delete(key)
|
|
808
823
|
return unless active
|
|
809
824
|
|
|
825
|
+
called_as = event.dig(:activity, :called_as)
|
|
826
|
+
active[:called_as] = called_as.to_s if called_as
|
|
810
827
|
finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
|
|
811
828
|
end
|
|
812
829
|
end
|
|
@@ -814,7 +831,7 @@ module Samagotchi
|
|
|
814
831
|
# Caller holds the mutex.
|
|
815
832
|
def finish_tool_record(active, status, waited_ms: 0)
|
|
816
833
|
record = active.slice(
|
|
817
|
-
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
834
|
+
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
|
|
818
835
|
).merge(
|
|
819
836
|
status: status,
|
|
820
837
|
completed_at: now.iso8601(3),
|
|
@@ -1020,7 +1037,7 @@ module Samagotchi
|
|
|
1020
1037
|
# (#snapshot adds the running turn). Caller holds the mutex.
|
|
1021
1038
|
def reset_totals
|
|
1022
1039
|
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
1023
|
-
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
1040
|
+
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, aliased: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
1024
1041
|
reprefill_sum: 0, reasoning_sum: 0,
|
|
1025
1042
|
cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
1026
1043
|
last_prefill_tps: nil, tps_source: nil }
|
|
@@ -1057,6 +1074,8 @@ module Samagotchi
|
|
|
1057
1074
|
def count_tool(record)
|
|
1058
1075
|
@totals[:tool_errors] += 1 if record[:status] == "error"
|
|
1059
1076
|
tally_into(@totals[:by_tool], [record])
|
|
1077
|
+
called_as = record[:called_as].to_s
|
|
1078
|
+
@totals[:aliased][called_as] = @totals[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
|
|
1060
1079
|
end
|
|
1061
1080
|
|
|
1062
1081
|
def tally_into(counts, tools)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_context_override"
|
|
4
|
+
require_relative "thinking"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A session's own settings that come before its model's, carried as one
|
|
8
|
+
# value on the paths that spawn a session from a whole setup
|
|
9
|
+
# (SessionManager.spawn_session, a continue, a plugin's fork, the web's
|
|
10
|
+
# create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
|
|
11
|
+
# +thinking+ a level Symbol (Thinking.session_level) or nil.
|
|
12
|
+
#
|
|
13
|
+
# It doesn't save itself: the session keeps each setting in its own field
|
|
14
|
+
# and file key, and resolves it on its own. A delegate child starts
|
|
15
|
+
# without one; a continue and a fork copy their session's (.of).
|
|
16
|
+
SessionSetup = Data.define(:llm_context, :thinking) do
|
|
17
|
+
def initialize(llm_context: nil, thinking: nil) = super
|
|
18
|
+
|
|
19
|
+
def empty? = llm_context.nil? && thinking.nil?
|
|
20
|
+
|
|
21
|
+
# A session's own setup, for a session that starts from it.
|
|
22
|
+
# @param session [Session, nil]
|
|
23
|
+
# @return [SessionSetup]
|
|
24
|
+
def self.of(session)
|
|
25
|
+
return new if session.nil?
|
|
26
|
+
|
|
27
|
+
new(llm_context: session.llm_context, thinking: session.thinking)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -339,7 +339,7 @@ module Samagotchi
|
|
|
339
339
|
end
|
|
340
340
|
return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
|
|
341
341
|
|
|
342
|
-
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.
|
|
342
|
+
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
|
|
343
343
|
note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
|
|
344
344
|
[snapshot, false, note]
|
|
345
345
|
end
|
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -22,6 +22,13 @@ module Samagotchi
|
|
|
22
22
|
{ thinking: event[:thinking].to_s, text: text.to_s }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# steer.cut_after: how long a generation streams only thinking before a
|
|
26
|
+
# message may cut it, in whole seconds (0 or less: cutting is off).
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
def self.cut_after
|
|
29
|
+
Config.get("steer.cut_after").to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
25
32
|
# +clock+: monotonic seconds; +controller+: the running turn's cancel
|
|
26
33
|
# controller (nil between turns), read only when a cut is due.
|
|
27
34
|
def initialize(clock:, controller:)
|
|
@@ -44,21 +51,25 @@ module Samagotchi
|
|
|
44
51
|
# Call it after the message is queued, with the #input_epoch read
|
|
45
52
|
# before queueing it. Outside any lock; a cut that lands just after the
|
|
46
53
|
# generation ended is harmless (the loop re-asks).
|
|
47
|
-
# @return [
|
|
54
|
+
# @return [Symbol] :now (a generation was cut now), :waits (the message
|
|
55
|
+
# waits for the thinking to pass steer.cut_after), or :off (cutting
|
|
56
|
+
# off for this source: a plugin, no turn, cut_after 0, a generation
|
|
57
|
+
# that isn't thinking-only, or a drain already took the message)
|
|
48
58
|
def cut_for_steer(source, epoch: nil)
|
|
49
|
-
return
|
|
59
|
+
return :off unless Steer.cuts?(source)
|
|
50
60
|
|
|
51
|
-
after =
|
|
52
|
-
return
|
|
61
|
+
after = self.class.cut_after
|
|
62
|
+
return :off unless after.positive?
|
|
53
63
|
|
|
54
64
|
ctrl = @controller.call
|
|
55
|
-
return
|
|
56
|
-
return cut!(ctrl, source.to_s) if @phase.cuttable?(after)
|
|
65
|
+
return :off unless ctrl
|
|
66
|
+
return cut!(ctrl, source.to_s) ? :now : :off if @phase.cuttable?(after)
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
waited = @waiting.wait!(source, epoch: epoch)
|
|
69
|
+
Log.info(:turn, "steer_cut_waits", source: source.to_s) if waited
|
|
70
|
+
waited ? :waits : :off
|
|
60
71
|
rescue StandardError
|
|
61
|
-
|
|
72
|
+
:off
|
|
62
73
|
end
|
|
63
74
|
|
|
64
75
|
# The drains that took input so far (WaitingSteer#epoch): read it before
|
|
@@ -100,7 +111,7 @@ module Samagotchi
|
|
|
100
111
|
def recheck
|
|
101
112
|
return unless @waiting.waiting?
|
|
102
113
|
|
|
103
|
-
after =
|
|
114
|
+
after = self.class.cut_after
|
|
104
115
|
return unless after.positive? && @phase.cuttable?(after)
|
|
105
116
|
|
|
106
117
|
source = @waiting.take
|
|
@@ -4,6 +4,7 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../config"
|
|
5
5
|
require_relative "../model_profile"
|
|
6
6
|
require_relative "../session_manager"
|
|
7
|
+
require_relative "../thinking_command"
|
|
7
8
|
require_relative "../bridge_client"
|
|
8
9
|
require_relative "../parent_report"
|
|
9
10
|
require_relative "../cli/exit"
|
|
@@ -40,16 +41,20 @@ module Samagotchi
|
|
|
40
41
|
# (LLMContextOverride.update): a new session starts with them; a
|
|
41
42
|
# resumed or attached one's worker gets a /llm-context, after the
|
|
42
43
|
# /model
|
|
44
|
+
# @param thinking [String, nil] --thinking as typed: a new session
|
|
45
|
+
# starts with it as its own level; a resumed or attached one's
|
|
46
|
+
# worker gets a /thinking, after those
|
|
43
47
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
44
48
|
# :failed when the --model switch didn't go through, or (input from a
|
|
45
49
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
46
50
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
47
|
-
memories: [], muted_memories: [], llm_context: {})
|
|
51
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil)
|
|
48
52
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
49
|
-
memories: memories, muted_memories: muted_memories, llm_context: llm_context)
|
|
53
|
+
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking)
|
|
50
54
|
commands = []
|
|
51
55
|
commands << "/model #{model}" if model && (attach || resume)
|
|
52
56
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
57
|
+
commands << "#{ThinkingCommand::NAME} #{thinking}" if (attach || resume) && thinking
|
|
53
58
|
first_command = commands.size > 1 ? commands : commands.first
|
|
54
59
|
surface = open_surface
|
|
55
60
|
attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
|
|
@@ -111,7 +116,7 @@ module Samagotchi
|
|
|
111
116
|
# @return [BridgeClient] a client for the live Bridge of the session
|
|
112
117
|
# @raise [Error]
|
|
113
118
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
114
|
-
memories: [], muted_memories: [], llm_context: {}, err: $stderr)
|
|
119
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, err: $stderr)
|
|
115
120
|
sd = state_dir || Session.default_state_dir
|
|
116
121
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
|
|
117
122
|
if attach
|
|
@@ -127,7 +132,8 @@ module Samagotchi
|
|
|
127
132
|
else
|
|
128
133
|
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
129
134
|
memories: memories, muted_memories: muted_memories,
|
|
130
|
-
llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context)
|
|
135
|
+
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
136
|
+
thinking: Thinking.session_level(thinking)))
|
|
131
137
|
end
|
|
132
138
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
133
139
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -14,6 +14,7 @@ require_relative "reline_seam"
|
|
|
14
14
|
require_relative "version_lines"
|
|
15
15
|
require_relative "../bridge/turn_accumulator"
|
|
16
16
|
require_relative "../bridge_client"
|
|
17
|
+
require_relative "../delivery"
|
|
17
18
|
require_relative "../log"
|
|
18
19
|
require_relative "../context_note"
|
|
19
20
|
require_relative "../steer"
|
|
@@ -380,6 +381,14 @@ module Samagotchi
|
|
|
380
381
|
return answer_question(text)
|
|
381
382
|
end
|
|
382
383
|
|
|
384
|
+
# /cut TEXT and /queue TEXT are this UI's own deliveries, read before
|
|
385
|
+
# every command check (SessionCommands registers them for Tab and
|
|
386
|
+
# /help only). A plain line sends no delivery: it goes in at the
|
|
387
|
+
# running turn's next step, and never cuts.
|
|
388
|
+
if (choice = steer_choice(text))
|
|
389
|
+
return submit_delivery(*choice)
|
|
390
|
+
end
|
|
391
|
+
|
|
383
392
|
# The terminal's own commands (SessionCommands registers them, the
|
|
384
393
|
# REPL reads the same words). Before the continue offer: the
|
|
385
394
|
# leaving ones are no answer to it.
|
|
@@ -414,6 +423,20 @@ module Samagotchi
|
|
|
414
423
|
nil
|
|
415
424
|
end
|
|
416
425
|
|
|
426
|
+
# /cut TEXT and /queue TEXT while a turn runs. Only /cut goes in P1:
|
|
427
|
+
# the worker's drain doesn't skip queued files yet, so a queued
|
|
428
|
+
# message would steer the running turn; /queue says so and sends
|
|
429
|
+
# nothing. At the prompt both run the text as a normal turn.
|
|
430
|
+
# @return [nil]
|
|
431
|
+
def submit_delivery(choice, text)
|
|
432
|
+
return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
|
|
433
|
+
return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue && @running
|
|
434
|
+
|
|
435
|
+
# To an idle session it is an ordinary prompt: no delivery to send.
|
|
436
|
+
send_prompt(text, delivery: @running ? Delivery::CUT : nil)
|
|
437
|
+
nil
|
|
438
|
+
end
|
|
439
|
+
|
|
417
440
|
# @return [Symbol] :detach
|
|
418
441
|
def detach(line)
|
|
419
442
|
@view.finish_thinking_spinner
|
|
@@ -573,6 +596,9 @@ module Samagotchi
|
|
|
573
596
|
end
|
|
574
597
|
|
|
575
598
|
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
599
|
+
# /queue needs a worker whose drain skips queued input files (P3):
|
|
600
|
+
# until then a queued message would steer the running turn.
|
|
601
|
+
QUEUE_UNAVAILABLE = "(/queue isn't available in an attached session yet)"
|
|
576
602
|
|
|
577
603
|
# A command queued for after the running turn (command_queued with
|
|
578
604
|
# waits): its line now (another client's) and a note; its command_ran
|
|
@@ -661,19 +687,40 @@ module Samagotchi
|
|
|
661
687
|
end
|
|
662
688
|
end
|
|
663
689
|
|
|
664
|
-
|
|
690
|
+
# What a prompt's 202 says became of it, or nil when there is nothing
|
|
691
|
+
# to say: the dim line both TUIs print for a delivery. A plain line
|
|
692
|
+
# for a running turn goes in at its next step; a cut's outcome comes
|
|
693
|
+
# from the ack (the worker knows whether cutting is on for its model).
|
|
694
|
+
# An idle session's prompt simply runs, and a prompt with images runs
|
|
695
|
+
# as its own next turn (never merging mid-turn), so nothing is said.
|
|
696
|
+
# @param ack [Hash, nil] the Bridge's 202 body
|
|
697
|
+
# @param delivery [String, nil] what was asked for
|
|
698
|
+
# @return [String, nil]
|
|
699
|
+
def sent_note(ack, delivery: nil, images: [], running: false)
|
|
700
|
+
return nil unless running && images.empty? && ack.is_a?(Hash)
|
|
701
|
+
|
|
702
|
+
return NEXT_STEP_NOTE unless delivery == Delivery::CUT
|
|
703
|
+
|
|
704
|
+
steer_cut_note(ack["cut"].to_s.to_sym)
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def send_prompt(text, delivery: nil)
|
|
665
708
|
persist_recent_history(text)
|
|
666
709
|
images = attach_images(text)
|
|
667
710
|
return own_prompt_failed if images.nil?
|
|
668
711
|
|
|
712
|
+
running = @running
|
|
669
713
|
options = { prompt: text, client_id: @client_id }
|
|
670
714
|
options[:no_interrupt] = true if @no_interrupt
|
|
671
715
|
options[:images] = images unless images.empty?
|
|
716
|
+
options[:delivery] = delivery if delivery
|
|
672
717
|
reply = @client.post_turn(**options)
|
|
673
718
|
if reply.status == 202
|
|
674
719
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
675
720
|
@sent_ids << enqueued_id if enqueued_id
|
|
676
721
|
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
722
|
+
note = sent_note(reply.json, delivery: delivery, images: images, running: running)
|
|
723
|
+
@screen.commit(note) if note
|
|
677
724
|
return
|
|
678
725
|
end
|
|
679
726
|
|