samagotchi 0.62.0 → 0.64.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 +78 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +12 -2
- data/docs/cli.md +20 -9
- data/docs/configuration.md +16 -9
- data/docs/context.md +13 -3
- data/docs/hooks.md +6 -3
- data/docs/internals/engine.md +10 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +96 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +11 -8
- data/docs/sub-agent.md +27 -4
- data/docs/testing.md +13 -4
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +41 -5
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +6 -3
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_config.rb +6 -6
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/row_refs.rb +3 -4
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
- data/lib/samagotchi/terminal_ui.rb +103 -68
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +33 -8
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -4
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/app.js +53 -91
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +6 -4
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/question_cards.js +3 -2
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/timing.js +2 -8
- data/lib/samagotchi/web/public/turn_html.js +119 -59
- data/lib/samagotchi/web/public/turn_model.js +13 -3
- data/lib/samagotchi/web/public/turn_view.js +73 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +53 -35
- data/lib/samagotchi/worker_inbound.rb +12 -11
- metadata +9 -1
|
@@ -158,11 +158,39 @@ module Samagotchi
|
|
|
158
158
|
nil
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
# A turn's reply: output/<timestamp>.txt, or
|
|
162
|
+
# output/<timestamp>-<enqueued_id>.txt for a turn that ran an input
|
|
163
|
+
# with an id, so a wait for that input's turn (ReplyWait's turn:) finds
|
|
164
|
+
# its own reply. The names still sort by time ("-" sorts before ".",
|
|
165
|
+
# so an id'd name and a bare one of the same timestamp keep order);
|
|
166
|
+
# every reader globs *.txt.
|
|
167
|
+
# @param enqueued_id [String, nil] the turn's input id (LastTurn#enqueued_id)
|
|
168
|
+
def self.write_output(session_dir, response, enqueued_id: nil)
|
|
162
169
|
output_dir = File.join(session_dir, OUTPUT_DIR)
|
|
163
170
|
FileUtils.mkdir_p(output_dir)
|
|
164
171
|
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
165
|
-
AtomicFile.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
|
|
172
|
+
AtomicFile.write(File.join(output_dir, "#{output_name(timestamp, enqueued_id)}.txt"), response.to_s)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# An id that is safe in a file name (a Bridge's or deliver_turn's
|
|
176
|
+
# uuid); anything else leaves the name bare.
|
|
177
|
+
ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
178
|
+
|
|
179
|
+
def self.output_name(timestamp, enqueued_id)
|
|
180
|
+
id = enqueued_id.to_s
|
|
181
|
+
ENQUEUED_ID_NAME.match?(id) ? "#{timestamp}-#{id}" : timestamp
|
|
182
|
+
end
|
|
183
|
+
private_class_method :output_name
|
|
184
|
+
|
|
185
|
+
# @return [String, nil] the reply filename of the turn that ran the
|
|
186
|
+
# input +enqueued_id+, or nil while it has none
|
|
187
|
+
def self.reply_for(session_dir, enqueued_id)
|
|
188
|
+
dir = File.join(session_dir, OUTPUT_DIR)
|
|
189
|
+
id = enqueued_id.to_s
|
|
190
|
+
return nil if id.empty? || !Dir.exist?(dir)
|
|
191
|
+
|
|
192
|
+
suffix = "-#{id}.txt"
|
|
193
|
+
Dir.children(dir).select { |f| f.end_with?(suffix) }.max
|
|
166
194
|
end
|
|
167
195
|
|
|
168
196
|
# The output files' text, oldest first; with +since_time+ only files
|
|
@@ -218,12 +246,40 @@ module Samagotchi
|
|
|
218
246
|
end
|
|
219
247
|
|
|
220
248
|
# 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
|
|
222
|
-
# image message (steering merges text only)
|
|
223
|
-
#
|
|
224
|
-
#
|
|
249
|
+
# of its own instead of merging it at a step boundary: a queued message
|
|
250
|
+
# (`delivery: queue`) or an image message (steering merges text only).
|
|
251
|
+
# The drain skips such a file without stopping at it, so a later text
|
|
252
|
+
# line still merges; the drain's file filter reads this one predicate,
|
|
253
|
+
# so what a boundary leaves changes in one place.
|
|
225
254
|
def self.waits_for_turn_end?(input_file)
|
|
226
|
-
|
|
255
|
+
data = JSON.parse(File.read(input_file))
|
|
256
|
+
data.is_a?(Hash) && (Array(data["images"]).any? || Delivery.queue?(data["delivery"]))
|
|
257
|
+
rescue JSON::ParserError, SystemCallError
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Whether an input file carrying +enqueued_id+ still waits unclaimed in
|
|
262
|
+
# input/ (no worker has taken it for a turn or a step yet).
|
|
263
|
+
def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
|
|
264
|
+
|
|
265
|
+
# How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
|
|
266
|
+
# (a queue or an image message: it runs as a turn of its own, never
|
|
267
|
+
# merged, #waits_for_turn_end?), :merges (a running turn's drain may
|
|
268
|
+
# take it as a steer), or nil when none waits.
|
|
269
|
+
# @return [Symbol, nil]
|
|
270
|
+
def self.waiting_input(session_dir, enqueued_id)
|
|
271
|
+
id = enqueued_id.to_s
|
|
272
|
+
return nil if id.empty?
|
|
273
|
+
|
|
274
|
+
find_new_input_files(session_dir).each do |file|
|
|
275
|
+
data = JSON.parse(File.read(file))
|
|
276
|
+
next unless data.is_a?(Hash) && data["enqueued_id"] == id
|
|
277
|
+
|
|
278
|
+
return Array(data["images"]).any? || Delivery.queue?(data["delivery"]) ? :turn_end : :merges
|
|
279
|
+
rescue JSON::ParserError, SystemCallError
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
nil
|
|
227
283
|
end
|
|
228
284
|
|
|
229
285
|
def self.claim_input_file(input_file)
|
|
@@ -205,8 +205,9 @@ module Samagotchi
|
|
|
205
205
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
206
206
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
207
207
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
208
|
-
# (Config.cli_env)
|
|
209
|
-
# SAMAGOTCHI_GUARDRAILS_
|
|
208
|
+
# (Config.cli_env) and the absolute log file. It unsets
|
|
209
|
+
# SAMAGOTCHI_GUARDRAILS_*, the session markers (SESSION_MARKER_ENV) and
|
|
210
|
+
# SAMAGOTCHI_HOSTS_JSON (HOSTS_JSON_ENV).
|
|
210
211
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
211
212
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
212
213
|
private_class_method def self.spawn_options(session)
|
|
@@ -223,14 +224,12 @@ module Samagotchi
|
|
|
223
224
|
Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
|
|
224
225
|
end
|
|
225
226
|
child_env = {}
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
nil
|
|
233
|
-
end
|
|
227
|
+
# The worker reads config.yml's hosts: as it starts, like every other
|
|
228
|
+
# setting: a host added or changed after this process (a parent
|
|
229
|
+
# worker, `chi web`) started reaches it. Unset, not just left out: a
|
|
230
|
+
# worker an older chi started still has one, and its delegates would
|
|
231
|
+
# inherit its start-time hosts.
|
|
232
|
+
child_env[HOSTS_JSON_ENV] = nil
|
|
234
233
|
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
235
234
|
# other flags this chi was started with.
|
|
236
235
|
child_env.merge!(Config.cli_env)
|
|
@@ -694,7 +693,7 @@ module Samagotchi
|
|
|
694
693
|
# so a resume right after spawns a fresh worker instead of finding the
|
|
695
694
|
# dying one.
|
|
696
695
|
# A turn still running is canceled first (STOP_CANCEL_WAIT), as the
|
|
697
|
-
# web's Cancel does: its worker saves it. The TERM stops (and saves)
|
|
696
|
+
# web's Cancel does, with reason STOP_CANCEL_REASON: its worker saves it. The TERM stops (and saves)
|
|
698
697
|
# what still runs (WorkerSignals), with a short grace after the marker.
|
|
699
698
|
# @return [Boolean, nil] with +wait+: whether the owner was gone in time
|
|
700
699
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
@@ -714,7 +713,14 @@ module Samagotchi
|
|
|
714
713
|
wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
|
|
715
714
|
end
|
|
716
715
|
|
|
716
|
+
# ConfigFile.hosts_config's override of config.yml's hosts:, which no
|
|
717
|
+
# spawned worker inherits (spawn_options).
|
|
718
|
+
HOSTS_JSON_ENV = "SAMAGOTCHI_HOSTS_JSON"
|
|
719
|
+
|
|
717
720
|
STOP_CANCEL_WAIT = 3.0
|
|
721
|
+
# The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
|
|
722
|
+
# not the user's Stop, which only ends the turn ("user").
|
|
723
|
+
STOP_CANCEL_REASON = "session_stopped"
|
|
718
724
|
|
|
719
725
|
# Cancel the session's running turn over its Bridge and wait (up to
|
|
720
726
|
# STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
|
|
@@ -723,7 +729,7 @@ module Samagotchi
|
|
|
723
729
|
return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
|
|
724
730
|
|
|
725
731
|
client = BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
|
|
726
|
-
return unless client && client.cancel(reason:
|
|
732
|
+
return unless client && client.cancel(reason: STOP_CANCEL_REASON).status == 202
|
|
727
733
|
|
|
728
734
|
BridgeClient.poll(STOP_CANCEL_WAIT) do
|
|
729
735
|
Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
|
|
@@ -1167,9 +1173,12 @@ module Samagotchi
|
|
|
1167
1173
|
# the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
|
|
1168
1174
|
# @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
|
|
1169
1175
|
# {status:, enqueued_id:, session_id:} for a file), {status: :refused,
|
|
1170
|
-
# code:, ack:} when the Bridge refused the
|
|
1171
|
-
#
|
|
1172
|
-
#
|
|
1176
|
+
# code:, ack:} when the Bridge refused the request (any 4xx with an
|
|
1177
|
+
# error: bad images, a delivery it doesn't run) or a queue would reach
|
|
1178
|
+
# a worker that doesn't queue (no file then: it would steer),
|
|
1179
|
+
# {status: :timeout, ack:} when it took the request but never answered
|
|
1180
|
+
# (no file then), or {status: :failed} when the input file couldn't be
|
|
1181
|
+
# written
|
|
1173
1182
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1174
1183
|
# @raise [ArgumentError] no such session
|
|
1175
1184
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
|
|
@@ -1178,20 +1187,28 @@ module Samagotchi
|
|
|
1178
1187
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1179
1188
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
1180
1189
|
if (client = bridge.call)
|
|
1190
|
+
# A worker from before queueing (no "delivery" feature) ignores the
|
|
1191
|
+
# field or refuses it; either way a file would steer the running turn.
|
|
1192
|
+
return queue_unavailable if Delivery.queue?(delivery) && !worker_queues?(session_dir)
|
|
1193
|
+
|
|
1181
1194
|
begin
|
|
1182
1195
|
options = { prompt: prompt, client_id: client_id }
|
|
1183
1196
|
options[:images] = images unless images.empty?
|
|
1184
|
-
# An old worker ignores the field: it cuts anyway (its own default)
|
|
1185
|
-
# and its file never carries a queue, so a queue would steer there.
|
|
1197
|
+
# An old worker ignores the field: it cuts anyway (its own default).
|
|
1186
1198
|
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1187
1199
|
reply = client.post_turn(**options)
|
|
1188
1200
|
ack = reply.json
|
|
1189
1201
|
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
1190
|
-
if ack.is_a?(Hash) && ack["error"] == "bad_images"
|
|
1191
|
-
return { status: :refused, code: reply.status, ack: ack }
|
|
1192
|
-
end
|
|
1193
1202
|
# Read after its deadline and dropped: a file would run it after all.
|
|
1194
1203
|
return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
|
|
1204
|
+
# Any other 4xx with an error is a refusal (bad images, a delivery
|
|
1205
|
+
# it doesn't run, a client bug): a file would bypass it, and a
|
|
1206
|
+
# refused queue would steer. A 413 (a body over the Bridge's
|
|
1207
|
+
# limit, a big piped chi send) and a 5xx keep the file fallback
|
|
1208
|
+
# below, which carries the delivery (a queue too).
|
|
1209
|
+
if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
|
|
1210
|
+
return { status: :refused, code: reply.status, ack: ack }
|
|
1211
|
+
end
|
|
1195
1212
|
rescue Errno::ETIMEDOUT
|
|
1196
1213
|
# The Bridge drops a turn it reads after the request's deadline
|
|
1197
1214
|
# (BridgeClient#post_turn), so a worker that wakes later won't run
|
|
@@ -1222,6 +1239,18 @@ module Samagotchi
|
|
|
1222
1239
|
{ status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
|
|
1223
1240
|
end
|
|
1224
1241
|
|
|
1242
|
+
# Whether the session's live worker runs delivery: queue (its sidecar
|
|
1243
|
+
# names the feature). No sidecar to read (the client came from a stand-in,
|
|
1244
|
+
# or it went between the wait and here): true, the post finds out.
|
|
1245
|
+
private_class_method def self.worker_queues?(session_dir)
|
|
1246
|
+
sidecar = WorkerSidecar.read(session_dir)
|
|
1247
|
+
sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
private_class_method def self.queue_unavailable
|
|
1251
|
+
{ status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE }
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1225
1254
|
private_class_method def self.turn_timeout
|
|
1226
1255
|
{ status: :timeout, ack: { "error" => "worker_timeout",
|
|
1227
1256
|
"detail" => "the session's worker did not answer, so the message was not sent" } }
|
|
@@ -46,12 +46,15 @@ module Samagotchi
|
|
|
46
46
|
# @param thinking [String, nil] --thinking as typed: a new session
|
|
47
47
|
# starts with it as its own level; a resumed or attached one's
|
|
48
48
|
# worker gets a /thinking, after those
|
|
49
|
+
# @param state_dir [String, nil] where the sessions are (the default
|
|
50
|
+
# state dir by default): the worker's, for the attached loop too
|
|
49
51
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
50
52
|
# :failed when the --model switch didn't go through, or (input from a
|
|
51
53
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
52
54
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
53
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
|
|
54
|
-
|
|
55
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
|
|
56
|
+
state_dir ||= Session.default_state_dir
|
|
57
|
+
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
55
58
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
56
59
|
appended_prompt_files: appended_prompt_files)
|
|
57
60
|
commands = []
|
|
@@ -65,7 +68,7 @@ module Samagotchi
|
|
|
65
68
|
first_command: first_command, no_interrupt: no_interrupt,
|
|
66
69
|
default_input: default_input && !prompt && !attach && !resume,
|
|
67
70
|
wait_at_eof: !$stdin.tty?,
|
|
68
|
-
parent_answers: Guardrails::ParentApprovals.parent_process
|
|
71
|
+
parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
|
|
69
72
|
ended = begin
|
|
70
73
|
attached.run
|
|
71
74
|
ensure
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../client_id"
|
|
4
|
+
require_relative "../composer_route"
|
|
4
5
|
require_relative "event_renderer"
|
|
5
6
|
require_relative "formatting"
|
|
6
7
|
require_relative "../prompt_history"
|
|
@@ -100,14 +101,18 @@ module Samagotchi
|
|
|
100
101
|
# (the -p one too) have had their turns; if one failed, #run says so
|
|
101
102
|
# @param children_counts [#call] session id -> its delegates'
|
|
102
103
|
# ChildrenStatus::Counts, the status row's children segment
|
|
104
|
+
# @param state_dir [String] the state dir the worker was found in: its
|
|
105
|
+
# session folder (@path images, saved records) is under it
|
|
103
106
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
104
107
|
default_input: false, wait_at_eof: false, parent_answers: false,
|
|
105
108
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
106
109
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
107
110
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
108
111
|
installed_version: -> { InstalledVersions.new.newest },
|
|
109
|
-
|
|
112
|
+
state_dir: Session.default_state_dir,
|
|
113
|
+
children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
|
|
110
114
|
@client = client
|
|
115
|
+
@state_dir = state_dir
|
|
111
116
|
@installed_version = installed_version
|
|
112
117
|
@delete_session = delete_session
|
|
113
118
|
@archive_session = archive_session
|
|
@@ -342,7 +347,7 @@ module Samagotchi
|
|
|
342
347
|
id = @client.session_id
|
|
343
348
|
return :gone if Session.stopped_marker?(id)
|
|
344
349
|
|
|
345
|
-
BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
|
|
350
|
+
BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
|
|
346
351
|
end
|
|
347
352
|
|
|
348
353
|
# The stream moved to a new worker (a restart): requests go there too.
|
|
@@ -383,61 +388,72 @@ module Samagotchi
|
|
|
383
388
|
end
|
|
384
389
|
|
|
385
390
|
# /cut TEXT and /queue TEXT are this UI's own deliveries, read before
|
|
386
|
-
# every command check (SessionCommands registers them
|
|
387
|
-
# /help only). A plain line sends no delivery: it goes in
|
|
388
|
-
# running turn's next step, and never cuts.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
end
|
|
391
|
+
# every command check (ComposerRoute; SessionCommands registers them
|
|
392
|
+
# for Tab and /help only). A plain line sends no delivery: it goes in
|
|
393
|
+
# at the running turn's next step, and never cuts.
|
|
394
|
+
route = ComposerRoute.call(text, running: @running, registry: command_registry)
|
|
395
|
+
return submit_delivery(route) if route.choice
|
|
392
396
|
|
|
393
397
|
# The terminal's own commands (SessionCommands registers them, the
|
|
394
398
|
# REPL reads the same words). Before the continue offer: the
|
|
395
399
|
# leaving ones are no answer to it.
|
|
396
|
-
local =
|
|
400
|
+
local = route.local
|
|
397
401
|
return submit(nil) if local == :detach
|
|
398
402
|
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
399
403
|
return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
|
|
400
404
|
return exit_and_archive if local == :archive
|
|
401
405
|
|
|
402
406
|
if @continue_offer
|
|
403
|
-
return answer_continue(text) unless local ||
|
|
407
|
+
return answer_continue(text) unless local || route.kind == :command
|
|
404
408
|
|
|
405
409
|
# Not an answer: the read left no echo, so show what ran.
|
|
406
410
|
echo_answer(text)
|
|
407
411
|
end
|
|
408
|
-
return if
|
|
412
|
+
return if route.kind == :empty
|
|
409
413
|
|
|
410
414
|
if local == :stats
|
|
411
415
|
show_stats
|
|
412
416
|
elsif local == :recap
|
|
413
417
|
show_recap
|
|
414
|
-
elsif
|
|
418
|
+
elsif route.kind == :command
|
|
415
419
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
416
420
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
417
421
|
send_command(text)
|
|
418
|
-
elsif
|
|
422
|
+
elsif route.kind == :typo
|
|
419
423
|
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
420
|
-
@screen.commit(
|
|
424
|
+
@screen.commit(route.reply)
|
|
421
425
|
else
|
|
422
426
|
send_prompt(text)
|
|
423
427
|
end
|
|
424
428
|
nil
|
|
425
429
|
end
|
|
426
430
|
|
|
427
|
-
# /cut TEXT and /queue TEXT
|
|
428
|
-
#
|
|
429
|
-
#
|
|
430
|
-
#
|
|
431
|
+
# /cut TEXT and /queue TEXT (+route+ has a choice). During a turn the
|
|
432
|
+
# text goes with its delivery: a cut, or queued to run as a turn of
|
|
433
|
+
# its own after this one. A worker that doesn't queue (its sidecar
|
|
434
|
+
# names no "delivery" feature) would steer a queue, so it is not sent.
|
|
435
|
+
# At the prompt both send the text as typed, a normal turn: no local
|
|
436
|
+
# word or typo check (the worker's Bridge runs a session command as
|
|
437
|
+
# the command).
|
|
438
|
+
# @param route [ComposerRoute::Route]
|
|
431
439
|
# @return [nil]
|
|
432
|
-
def submit_delivery(
|
|
433
|
-
return @screen.commit(STEER_CHOICE_USAGE) if
|
|
434
|
-
return @screen.commit(
|
|
440
|
+
def submit_delivery(route)
|
|
441
|
+
return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
|
|
442
|
+
return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if Delivery.queue?(route.delivery) && !worker_queues?
|
|
435
443
|
|
|
436
444
|
# To an idle session it is an ordinary prompt: no delivery to send.
|
|
437
|
-
send_prompt(text, delivery:
|
|
445
|
+
send_prompt(route.text, delivery: route.delivery)
|
|
438
446
|
nil
|
|
439
447
|
end
|
|
440
448
|
|
|
449
|
+
# Whether the attached worker runs delivery: queue (its sidecar names
|
|
450
|
+
# Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
|
|
451
|
+
# answers (a P0-P2 worker refuses a queue: #send_prompt).
|
|
452
|
+
def worker_queues?
|
|
453
|
+
sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
|
|
454
|
+
sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
|
|
455
|
+
end
|
|
456
|
+
|
|
441
457
|
# @return [Symbol] :detach
|
|
442
458
|
def detach(line)
|
|
443
459
|
@view.finish_thinking_spinner
|
|
@@ -597,9 +613,9 @@ module Samagotchi
|
|
|
597
613
|
end
|
|
598
614
|
|
|
599
615
|
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
600
|
-
|
|
601
|
-
#
|
|
602
|
-
|
|
616
|
+
QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
|
|
617
|
+
# /queue to a worker from before queueing, which would steer it.
|
|
618
|
+
QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
|
|
603
619
|
|
|
604
620
|
# A command queued for after the running turn (command_queued with
|
|
605
621
|
# waits): its line now (another client's) and a note; its command_ran
|
|
@@ -683,7 +699,11 @@ module Samagotchi
|
|
|
683
699
|
end
|
|
684
700
|
@continue_answer = nil
|
|
685
701
|
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
686
|
-
|
|
702
|
+
# Back to the > prompt: with the text a question set aside meanwhile
|
|
703
|
+
# (#close_question), else what the ? read holds.
|
|
704
|
+
set_aside = @set_aside
|
|
705
|
+
@set_aside = nil
|
|
706
|
+
set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
|
|
687
707
|
end
|
|
688
708
|
|
|
689
709
|
# The notes slot shows the offer's choices while it waits (a question
|
|
@@ -701,14 +721,20 @@ module Samagotchi
|
|
|
701
721
|
# What a prompt's 202 says became of it, or nil when there is nothing
|
|
702
722
|
# to say: the dim line both TUIs print for a delivery. A plain line
|
|
703
723
|
# for a running turn goes in at its next step; a cut's outcome comes
|
|
704
|
-
# from the ack (the worker knows whether cutting is on for its model)
|
|
724
|
+
# from the ack (the worker knows whether cutting is on for its model);
|
|
725
|
+
# a queued one waits for the turn's end (the ack says queue).
|
|
705
726
|
# An idle session's prompt simply runs, and a prompt with images runs
|
|
706
727
|
# as its own next turn (never merging mid-turn), so nothing is said.
|
|
707
728
|
# @param ack [Hash, nil] the Bridge's 202 body
|
|
708
729
|
# @param delivery [String, nil] what was asked for
|
|
709
730
|
# @return [String, nil]
|
|
710
731
|
def sent_note(ack, delivery: nil, images: [], running: false)
|
|
711
|
-
return nil unless running &&
|
|
732
|
+
return nil unless running && ack.is_a?(Hash)
|
|
733
|
+
# The Bridge acks queue only while a turn ran at enqueue; a /queue
|
|
734
|
+
# it acks next_step found the turn over: it is the next turn.
|
|
735
|
+
return QUEUED_NOTE if ack["delivery"] == Delivery::QUEUE
|
|
736
|
+
return QUEUE_RUNS_NOW if Delivery.queue?(delivery)
|
|
737
|
+
return nil unless images.empty?
|
|
712
738
|
|
|
713
739
|
return NEXT_STEP_NOTE unless delivery == Delivery::CUT
|
|
714
740
|
|
|
@@ -740,6 +766,8 @@ module Samagotchi
|
|
|
740
766
|
detail = reply.json&.fetch("error", nil)
|
|
741
767
|
# Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
|
|
742
768
|
return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
|
|
769
|
+
# A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
|
|
770
|
+
return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if detail == "delivery_unavailable"
|
|
743
771
|
|
|
744
772
|
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
|
|
745
773
|
rescue SystemCallError, IOError => e
|
|
@@ -802,7 +830,7 @@ module Samagotchi
|
|
|
802
830
|
paths = ImageInput.extract(text)
|
|
803
831
|
return [] if paths.empty?
|
|
804
832
|
|
|
805
|
-
session_dir = Session.session_dir(@client.session_id)
|
|
833
|
+
session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
|
|
806
834
|
paths.map do |image|
|
|
807
835
|
ref = ImageStore.ingest(session_dir, path: image[:path])
|
|
808
836
|
{ file: ref[:file], name: ref[:name] }
|
|
@@ -865,7 +893,9 @@ module Samagotchi
|
|
|
865
893
|
|
|
866
894
|
@view.finish_thinking_spinner
|
|
867
895
|
# What was typed at the prompt waits for the question to close.
|
|
868
|
-
|
|
896
|
+
# Not over text already set aside (a question that closed while a
|
|
897
|
+
# continue offer waits): the ? read holds an answer then, if anything.
|
|
898
|
+
@set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
|
|
869
899
|
@question = QuestionPrompt.new(pending)
|
|
870
900
|
@question_pending = pending
|
|
871
901
|
# The step-limit question stands for the continue offer's own slot
|
|
@@ -987,10 +1017,14 @@ module Samagotchi
|
|
|
987
1017
|
@screen.clear_slot(:notes)
|
|
988
1018
|
@screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
|
|
989
1019
|
@question = nil
|
|
1020
|
+
# A continue offer still waiting keeps the ? prompt (the step-limit
|
|
1021
|
+
# question closes before its offer resolves): the text set aside
|
|
1022
|
+
# waits for the > prompt (#continue_resolved).
|
|
1023
|
+
return sync_continue_slot if @continue_offer
|
|
1024
|
+
|
|
990
1025
|
set_aside = @set_aside
|
|
991
1026
|
@set_aside = nil
|
|
992
1027
|
sync_prompt(keep_text: false, prefill: set_aside)
|
|
993
|
-
sync_continue_slot if @continue_offer
|
|
994
1028
|
end
|
|
995
1029
|
|
|
996
1030
|
# Restart the open read when its prompt no longer fits (a question or a
|
|
@@ -1195,7 +1229,10 @@ module Samagotchi
|
|
|
1195
1229
|
return unless last_user
|
|
1196
1230
|
|
|
1197
1231
|
@screen.commit(join_prompt_line(exchange[last_user]))
|
|
1198
|
-
|
|
1232
|
+
# An image-only turn's text names its images already.
|
|
1233
|
+
unless ImageRef.placeholder_only?(exchange[last_user][:content])
|
|
1234
|
+
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1235
|
+
end
|
|
1199
1236
|
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1200
1237
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
1201
1238
|
failed = join_failed_record(exchange[last_user])
|
|
@@ -1244,7 +1281,7 @@ module Samagotchi
|
|
|
1244
1281
|
# machine's state dir, as #attach_images): none for a prompt with no
|
|
1245
1282
|
# turn id (an older session) or a session with no analytics.
|
|
1246
1283
|
def join_tool_records(prompt)
|
|
1247
|
-
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
|
|
1284
|
+
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id, state_dir: @state_dir), prompt[:turn_id] || prompt["turn_id"])
|
|
1248
1285
|
rescue Session::InvalidId
|
|
1249
1286
|
[]
|
|
1250
1287
|
end
|
|
@@ -1254,7 +1291,7 @@ module Samagotchi
|
|
|
1254
1291
|
# canceled or older turn — or one saved before the failure field —
|
|
1255
1292
|
# draws its answer as before.
|
|
1256
1293
|
def join_failed_record(prompt)
|
|
1257
|
-
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
|
|
1294
|
+
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id, state_dir: @state_dir),
|
|
1258
1295
|
prompt[:turn_id] || prompt["turn_id"])
|
|
1259
1296
|
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
1260
1297
|
|
|
@@ -1394,7 +1431,8 @@ module Samagotchi
|
|
|
1394
1431
|
return if own?(event[:client_id])
|
|
1395
1432
|
|
|
1396
1433
|
@shown_enqueued << event[:enqueued_id]
|
|
1397
|
-
|
|
1434
|
+
line = prompt_line(event[:client_id], event[:prompt])
|
|
1435
|
+
@screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
|
|
1398
1436
|
end
|
|
1399
1437
|
|
|
1400
1438
|
def start_turn(event)
|
|
@@ -108,14 +108,8 @@ module Samagotchi
|
|
|
108
108
|
CUT_NOW_NOTE = "(cut in now)"
|
|
109
109
|
CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
|
|
110
110
|
CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
|
|
111
|
+
# A bare /cut or /queue (ComposerRoute's :usage).
|
|
111
112
|
STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
|
|
112
|
-
# The UIs' own words for a delivery, listed for Tab and /help only
|
|
113
|
-
# (a local: entry, so no command or bundle may use the names).
|
|
114
|
-
CUT_COMMAND = "/cut"
|
|
115
|
-
QUEUE_COMMAND = "/queue"
|
|
116
|
-
# The word, then any whitespace and the text (as the registry's
|
|
117
|
-
# entries match them, so a line Tab or /help knows is never a prompt).
|
|
118
|
-
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
119
113
|
|
|
120
114
|
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
121
115
|
# @param after [Integer, nil] the run's cut wait (the model's own
|
|
@@ -133,17 +127,6 @@ module Samagotchi
|
|
|
133
127
|
# The same words, for a UI that includes the module.
|
|
134
128
|
def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
|
|
135
129
|
|
|
136
|
-
# The choice a mid-turn line carries, as [:cut, "TEXT"] or
|
|
137
|
-
# [:queue, "TEXT"]; nil for any other line. The text is "" when the
|
|
138
|
-
# line named the choice without one.
|
|
139
|
-
# @param line [String, nil]
|
|
140
|
-
# @return [Array(Symbol, String), nil]
|
|
141
|
-
def steer_choice(line)
|
|
142
|
-
match = STEER_CHOICE.match(line.to_s.strip) or return nil
|
|
143
|
-
|
|
144
|
-
[match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
|
|
145
|
-
end
|
|
146
|
-
|
|
147
130
|
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
148
131
|
# off)": a batch of LLM context edits went in (LLMContextNotice builds
|
|
149
132
|
# the line; the web prints the same), dim like the retry row.
|
|
@@ -363,7 +346,8 @@ module Samagotchi
|
|
|
363
346
|
|
|
364
347
|
# A cancel's reason as the UIs name it (anything else as it is);
|
|
365
348
|
# spec/shared/labels_matrix.json pins it for both.
|
|
366
|
-
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped"
|
|
349
|
+
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped",
|
|
350
|
+
"session_stopped" => "session stopped" }.freeze
|
|
367
351
|
# A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
|
|
368
352
|
# not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
|
|
369
353
|
STOP_MARK = "■"
|