samagotchi 0.83.0 → 0.85.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 +31 -1
- data/bin/chi +27 -2
- data/docs/architecture.md +10 -1
- data/docs/cli.md +17 -1
- data/docs/configuration.md +2 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +3 -2
- data/docs/plugins.md +31 -5
- data/docs/sessions.md +15 -2
- data/docs/sub-agent.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +54 -1
- data/lib/samagotchi/bridge_client.rb +12 -0
- data/lib/samagotchi/bundle_session_settings.rb +212 -0
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +12 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +5 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +2 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +36 -2
- data/lib/samagotchi/reply_wait.rb +8 -1
- data/lib/samagotchi/send_command.rb +24 -7
- data/lib/samagotchi/session.rb +16 -1
- data/lib/samagotchi/session_commands.rb +7 -4
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/session_setup.rb +14 -6
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
- data/lib/samagotchi/terminal_ui.rb +10 -2
- data/lib/samagotchi/testing/bundle.rb +31 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +41 -4
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- metadata +3 -1
|
@@ -7,6 +7,7 @@ require_relative "session"
|
|
|
7
7
|
require_relative "session_commands"
|
|
8
8
|
require_relative "session_inbox"
|
|
9
9
|
require_relative "session_manager"
|
|
10
|
+
require_relative "bundle_session_settings"
|
|
10
11
|
require_relative "session_chain"
|
|
11
12
|
require_relative "context_quote"
|
|
12
13
|
require_relative "reply_wait"
|
|
@@ -39,7 +40,7 @@ module Samagotchi
|
|
|
39
40
|
|
|
40
41
|
USAGE = <<~TEXT
|
|
41
42
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
42
|
-
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
|
|
43
|
+
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [-m TEXT] [--image PATH]...
|
|
43
44
|
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
44
45
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
46
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
@@ -65,8 +66,9 @@ module Samagotchi
|
|
|
65
66
|
(--new) start the next link of that session's chain:
|
|
66
67
|
in its folder, on its model (or --model's; one whose
|
|
67
68
|
host is gone falls back to the default, with a
|
|
68
|
-
warning), LLM context, thinking level and
|
|
69
|
-
with a note carrying its recap; it
|
|
69
|
+
warning), LLM context, thinking level, toolkit and
|
|
70
|
+
bundle settings, with a note carrying its recap; it
|
|
71
|
+
is archived.
|
|
70
72
|
last:ID names the chain's latest link.
|
|
71
73
|
Without -m the new session waits idle. Refused for a
|
|
72
74
|
session continued already (naming the next link) or
|
|
@@ -84,6 +86,12 @@ module Samagotchi
|
|
|
84
86
|
(full, lean, child, a config or bundle one; default
|
|
85
87
|
config's toolkit.default), and tools declared on top
|
|
86
88
|
of it or left out (repeatable, a comma list too)
|
|
89
|
+
--set BUNDLE.KEY=VALUE
|
|
90
|
+
(--new) a bundle setting for this session, over
|
|
91
|
+
config.yml's bundles: (repeatable; a key its bundle's
|
|
92
|
+
manifest lists in session_settings:; 3, 2.5, true,
|
|
93
|
+
false and off are typed, the rest is text:
|
|
94
|
+
--set cost-cap.cap=3)
|
|
87
95
|
--wait wait for the answer and print it (one session); the
|
|
88
96
|
other lines go to stderr. Exit 3: it waits for an
|
|
89
97
|
answer from you: the question, its options and the
|
|
@@ -141,6 +149,7 @@ module Samagotchi
|
|
|
141
149
|
f.value "--toolkit"
|
|
142
150
|
f.value "--tool", key: :tools, repeat: true
|
|
143
151
|
f.value "--no-tool", key: :no_tools, repeat: true
|
|
152
|
+
f.value "--set", key: :sets, repeat: true
|
|
144
153
|
f.value "--image", key: :images, repeat: true
|
|
145
154
|
f.switch "--wait"
|
|
146
155
|
f.switch "--cut"
|
|
@@ -284,6 +293,7 @@ module Samagotchi
|
|
|
284
293
|
%i[dir model continues thinking toolkit].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
285
294
|
TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
|
|
286
295
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
296
|
+
return usage_error("--set needs --new") unless Array(options[:sets]).empty?
|
|
287
297
|
return usage_error("give session ids") if options[:ids].empty?
|
|
288
298
|
|
|
289
299
|
options
|
|
@@ -302,10 +312,11 @@ module Samagotchi
|
|
|
302
312
|
if options[:continues]
|
|
303
313
|
taken = [("--dir" if options[:dir])] +
|
|
304
314
|
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
|
|
305
|
-
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
|
|
315
|
+
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
|
|
316
|
+
[("--set" unless Array(options[:sets]).empty?)]
|
|
306
317
|
unless taken.compact.empty?
|
|
307
|
-
return usage_error("--continues takes the previous session's folder, LLM context, thinking level
|
|
308
|
-
"toolkit; " \
|
|
318
|
+
return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
|
|
319
|
+
"toolkit and bundle settings; " \
|
|
309
320
|
"leave out #{taken.compact.join(", ")}")
|
|
310
321
|
end
|
|
311
322
|
end
|
|
@@ -327,6 +338,11 @@ module Samagotchi
|
|
|
327
338
|
end
|
|
328
339
|
toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
|
|
329
340
|
options[:toolkit_value] = toolkit if toolkit
|
|
341
|
+
begin
|
|
342
|
+
options[:bundle_settings] = BundleSessionSettings.from_flags(options[:sets]) unless Array(options[:sets]).empty?
|
|
343
|
+
rescue BundleSessionSettings::Error => e
|
|
344
|
+
return usage_error(e.message)
|
|
345
|
+
end
|
|
330
346
|
|
|
331
347
|
if options[:dir]
|
|
332
348
|
options[:dir] = File.expand_path(options[:dir])
|
|
@@ -376,7 +392,8 @@ module Samagotchi
|
|
|
376
392
|
model_name: options[:model],
|
|
377
393
|
setup: SessionSetup.new(llm_context: options[:llm_context],
|
|
378
394
|
thinking: options[:thinking],
|
|
379
|
-
toolkit: options[:toolkit_value]
|
|
395
|
+
toolkit: options[:toolkit_value],
|
|
396
|
+
bundle_settings: options[:bundle_settings] || {}),
|
|
380
397
|
state_dir: @state_dir)
|
|
381
398
|
end
|
|
382
399
|
rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -208,6 +208,18 @@ module Samagotchi
|
|
|
208
208
|
@toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# The session's own bundle settings (chi --set BUNDLE.KEY=VALUE):
|
|
212
|
+
# {bundle => {key => value}}, frozen, {} for none and for a session from
|
|
213
|
+
# before them. Given when it was created and never changed; a respawn,
|
|
214
|
+
# a continue and a fork keep them (SessionSetup.of), a delegate child
|
|
215
|
+
# doesn't. Plugin::Context#settings lays them over config.yml's.
|
|
216
|
+
attr_reader :bundle_settings
|
|
217
|
+
|
|
218
|
+
# @param value [Hash, nil] the session file's form too
|
|
219
|
+
def bundle_settings=(value)
|
|
220
|
+
@bundle_settings = BundleSessionSettings.normalize(value)
|
|
221
|
+
end
|
|
222
|
+
|
|
211
223
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
212
224
|
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
213
225
|
def stopped_by
|
|
@@ -242,7 +254,7 @@ module Samagotchi
|
|
|
242
254
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
243
255
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
244
256
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
245
|
-
toolkit: nil, interrupted_turn: nil)
|
|
257
|
+
toolkit: nil, bundle_settings: {}, interrupted_turn: nil)
|
|
246
258
|
@id = id
|
|
247
259
|
@metadata_version = metadata_version
|
|
248
260
|
@mode = mode
|
|
@@ -272,6 +284,7 @@ module Samagotchi
|
|
|
272
284
|
self.reasoning_history = reasoning_history
|
|
273
285
|
self.appended_prompt_files = appended_prompt_files
|
|
274
286
|
self.toolkit = toolkit
|
|
287
|
+
self.bundle_settings = bundle_settings
|
|
275
288
|
self.interrupted_turn = interrupted_turn
|
|
276
289
|
@turn_checkpoint = nil
|
|
277
290
|
@archived = false
|
|
@@ -369,6 +382,7 @@ module Samagotchi
|
|
|
369
382
|
reasoning_history: setup.reasoning_history,
|
|
370
383
|
appended_prompt_files: setup.appended_prompt_files,
|
|
371
384
|
toolkit: setup.toolkit,
|
|
385
|
+
bundle_settings: setup.bundle_settings,
|
|
372
386
|
continues: continues
|
|
373
387
|
)
|
|
374
388
|
end
|
|
@@ -434,6 +448,7 @@ module Samagotchi
|
|
|
434
448
|
"reasoning_history" => nil,
|
|
435
449
|
"appended_prompt_files" => [],
|
|
436
450
|
"toolkit" => nil,
|
|
451
|
+
"bundle_settings" => {},
|
|
437
452
|
"interrupted_turn" => nil
|
|
438
453
|
}.freeze
|
|
439
454
|
|
|
@@ -123,13 +123,16 @@ module Samagotchi
|
|
|
123
123
|
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
124
124
|
# themselves (ComposerRoute.steer_choice) before any command check; the
|
|
125
125
|
# entries exist for Tab completion and /help. The web composer sends
|
|
126
|
-
# /cut too
|
|
127
|
-
#
|
|
128
|
-
# it is offered there.
|
|
126
|
+
# /cut too (its queue is Alt+Enter). Both terminals queue: the REPL in
|
|
127
|
+
# its own list, the attached TUI's worker in its input files.
|
|
129
128
|
registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
|
|
130
129
|
local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
131
130
|
registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
|
|
132
|
-
local: true, uis: [
|
|
131
|
+
local: true, uis: %i[repl attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
|
|
132
|
+
# Takes back the newest message this attached TUI queued, before it
|
|
133
|
+
# runs (the worker's POST turn/dequeue); the web has × on the bubble.
|
|
134
|
+
registry.register("/unqueue", "remove your newest queued message before it runs", local: true, uis: [:attached],
|
|
135
|
+
match: ->(text) { text.casecmp?("/unqueue") })
|
|
133
136
|
registry
|
|
134
137
|
end
|
|
135
138
|
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
8
|
require_relative "delivery"
|
|
9
|
+
require_relative "log"
|
|
9
10
|
require_relative "session"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -276,5 +277,72 @@ module Samagotchi
|
|
|
276
277
|
rescue Errno::ENOENT, Errno::EACCES
|
|
277
278
|
nil
|
|
278
279
|
end
|
|
280
|
+
|
|
281
|
+
# Where a removed queued input leaves its tombstone: input/removed/<id>,
|
|
282
|
+
# an empty file outside the *.json glob, so a wait for that input's
|
|
283
|
+
# turn (ReplyWait's turn:) learns it will never run (#removed?).
|
|
284
|
+
REMOVED_DIR = "removed"
|
|
285
|
+
# The worker's sidecar feature for POST turn/dequeue (Bridge::FEATURES).
|
|
286
|
+
UNQUEUE_FEATURE = "unqueue"
|
|
287
|
+
|
|
288
|
+
# Take back the unclaimed input file carrying +enqueued_id+ before a
|
|
289
|
+
# turn takes it (the Bridge's POST turn/dequeue, under the event log):
|
|
290
|
+
# only one that waits for the turn's end (a queue or an image message,
|
|
291
|
+
# #waits_for_turn_end?); a next-step file may be merged at any boundary.
|
|
292
|
+
# The file is claimed first, as the drain does (the rename is the race's
|
|
293
|
+
# one winner), then deleted, and its tombstone written.
|
|
294
|
+
# @return [Array(Symbol, Input)] [:removed, the input it held],
|
|
295
|
+
# [:not_queued, nil] for a next-step file, [:claimed, nil] when a
|
|
296
|
+
# turn took it (its *.json.processing is there), [:none, nil] for
|
|
297
|
+
# none
|
|
298
|
+
def self.take_queued_input(session_dir, enqueued_id)
|
|
299
|
+
id = enqueued_id.to_s
|
|
300
|
+
return [:none, nil] if id.empty?
|
|
301
|
+
|
|
302
|
+
find_new_input_files(session_dir).sort.each do |file|
|
|
303
|
+
data = JSON.parse(File.read(file))
|
|
304
|
+
next unless data.is_a?(Hash) && data["enqueued_id"] == id
|
|
305
|
+
return [:not_queued, nil] unless Array(data["images"]).any? || Delivery.queue?(data["delivery"])
|
|
306
|
+
|
|
307
|
+
claimed = claim_input_file(file) or return [:claimed, nil]
|
|
308
|
+
input = read_input(claimed)
|
|
309
|
+
mark_removed(session_dir, id)
|
|
310
|
+
FileUtils.rm_f(claimed)
|
|
311
|
+
return [:removed, input]
|
|
312
|
+
rescue JSON::ParserError, SystemCallError
|
|
313
|
+
next
|
|
314
|
+
end
|
|
315
|
+
claimed_input?(session_dir, id) ? [:claimed, nil] : [:none, nil]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Whether a claimed input file (*.json.processing: a turn's, now or
|
|
319
|
+
# left by a crash) carries +id+.
|
|
320
|
+
def self.claimed_input?(session_dir, id)
|
|
321
|
+
Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
|
|
322
|
+
data = JSON.parse(File.read(file))
|
|
323
|
+
data.is_a?(Hash) && data["enqueued_id"] == id
|
|
324
|
+
rescue JSON::ParserError, SystemCallError
|
|
325
|
+
false
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
private_class_method :claimed_input?
|
|
329
|
+
|
|
330
|
+
def self.mark_removed(session_dir, id)
|
|
331
|
+
return unless ENQUEUED_ID_NAME.match?(id)
|
|
332
|
+
|
|
333
|
+
dir = File.join(session_dir, INPUT_DIR, REMOVED_DIR)
|
|
334
|
+
FileUtils.mkdir_p(dir)
|
|
335
|
+
AtomicFile.write(File.join(dir, id), "")
|
|
336
|
+
rescue SystemCallError => e
|
|
337
|
+
Log.warn(:bridge, "removed_tombstone_write_failed", error: e.class.name, msg: e.message)
|
|
338
|
+
end
|
|
339
|
+
private_class_method :mark_removed
|
|
340
|
+
|
|
341
|
+
# @return [Boolean] whether the queued input +enqueued_id+ was removed
|
|
342
|
+
# before it ran (#take_queued_input's tombstone)
|
|
343
|
+
def self.removed?(session_dir, enqueued_id)
|
|
344
|
+
id = enqueued_id.to_s
|
|
345
|
+
ENQUEUED_ID_NAME.match?(id) && File.exist?(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id))
|
|
346
|
+
end
|
|
279
347
|
end
|
|
280
348
|
end
|
|
@@ -4,6 +4,7 @@ require_relative "llm_context_override"
|
|
|
4
4
|
require_relative "reasoning_history"
|
|
5
5
|
require_relative "thinking"
|
|
6
6
|
require_relative "toolkit"
|
|
7
|
+
require_relative "bundle_session_settings"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# A session's own settings that come before its model's, carried as one
|
|
@@ -15,20 +16,26 @@ module Samagotchi
|
|
|
15
16
|
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
16
17
|
# named (their text goes into the system prompt; [] with none),
|
|
17
18
|
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
18
|
-
# session config's toolkit.default then)
|
|
19
|
+
# session config's toolkit.default then), +bundle_settings+ the bundle
|
|
20
|
+
# settings chi --set gave ({bundle => {key => value}}, frozen; {} with
|
|
21
|
+
# none), which Plugin::Context#settings lays over config.yml's.
|
|
19
22
|
#
|
|
20
23
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
21
24
|
# and file key, and resolves it on its own. A delegate child starts
|
|
22
25
|
# without one (but the toolkit its delegate call named); a continue and a
|
|
23
26
|
# fork copy their session's (.of).
|
|
24
|
-
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit
|
|
25
|
-
|
|
27
|
+
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
|
|
28
|
+
:bundle_settings) do
|
|
29
|
+
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
30
|
+
bundle_settings: {})
|
|
26
31
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
27
|
-
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit
|
|
32
|
+
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
33
|
+
bundle_settings: BundleSessionSettings.normalize(bundle_settings))
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def empty?
|
|
31
|
-
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil?
|
|
37
|
+
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
|
|
38
|
+
bundle_settings.empty?
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
# A session's own setup, for a session that starts from it. Its
|
|
@@ -40,7 +47,8 @@ module Samagotchi
|
|
|
40
47
|
return new if session.nil?
|
|
41
48
|
|
|
42
49
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
43
|
-
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit)
|
|
50
|
+
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
|
|
51
|
+
bundle_settings: session.bundle_settings)
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
end
|
|
@@ -50,7 +50,9 @@ module Samagotchi
|
|
|
50
50
|
# session's toolkit (config's toolkit.default without); an existing
|
|
51
51
|
# session keeps its own
|
|
52
52
|
# @param toolkit_flags [Array<String>] which of those were given, for
|
|
53
|
-
# the line an existing session's run prints
|
|
53
|
+
# the line an existing session's run prints (--set too)
|
|
54
|
+
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
55
|
+
# a new session's setup carries them; an existing session keeps its own
|
|
54
56
|
# @param state_dir [String, nil] where the sessions are (the default
|
|
55
57
|
# state dir by default): the worker's, for the attached loop too
|
|
56
58
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
@@ -58,11 +60,12 @@ module Samagotchi
|
|
|
58
60
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
59
61
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
60
62
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
61
|
-
toolkit_flags: [], state_dir: nil)
|
|
63
|
+
toolkit_flags: [], bundle_settings: {}, state_dir: nil)
|
|
62
64
|
state_dir ||= Session.default_state_dir
|
|
63
65
|
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
64
66
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
65
|
-
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags
|
|
67
|
+
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
|
|
68
|
+
bundle_settings: bundle_settings)
|
|
66
69
|
commands = []
|
|
67
70
|
commands << "/model #{model}" if model && (attach || resume)
|
|
68
71
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -131,7 +134,7 @@ module Samagotchi
|
|
|
131
134
|
# @raise [Error]
|
|
132
135
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
133
136
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
134
|
-
toolkit_flags: [], err: $stderr)
|
|
137
|
+
toolkit_flags: [], bundle_settings: {}, err: $stderr)
|
|
135
138
|
sd = state_dir || Session.default_state_dir
|
|
136
139
|
if attach || resume
|
|
137
140
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
|
|
@@ -153,7 +156,7 @@ module Samagotchi
|
|
|
153
156
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
154
157
|
thinking: Thinking.session_level(thinking),
|
|
155
158
|
appended_prompt_files: appended_prompt_files,
|
|
156
|
-
toolkit: toolkit))
|
|
159
|
+
toolkit: toolkit, bundle_settings: bundle_settings))
|
|
157
160
|
end
|
|
158
161
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
159
162
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -5,6 +5,7 @@ require_relative "../composer_route"
|
|
|
5
5
|
require_relative "event_renderer"
|
|
6
6
|
require_relative "formatting"
|
|
7
7
|
require_relative "../prompt_history"
|
|
8
|
+
require_relative "../session_inbox"
|
|
8
9
|
require_relative "attached_view"
|
|
9
10
|
require_relative "status_row"
|
|
10
11
|
require_relative "input_support"
|
|
@@ -149,6 +150,10 @@ module Samagotchi
|
|
|
149
150
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
150
151
|
# the input when their turn fails (a replayed event must not).
|
|
151
152
|
@sent_ids = Set.new
|
|
153
|
+
# This run's prompts queued for after the running turn (their ack
|
|
154
|
+
# said queue), oldest first, until their turn starts or they are
|
|
155
|
+
# removed: /unqueue takes back the newest.
|
|
156
|
+
@my_queued = []
|
|
152
157
|
@wait_at_eof = wait_at_eof
|
|
153
158
|
# Sent and not ended yet (by enqueued_id), the ones merged into the
|
|
154
159
|
# running turn, whether the input has ended, and whether one of this
|
|
@@ -260,6 +265,7 @@ module Samagotchi
|
|
|
260
265
|
take_session_state(event[:session_state_snapshot] || {})
|
|
261
266
|
return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
|
|
262
267
|
when :turn_enqueued then show_enqueued(event)
|
|
268
|
+
when :turn_dequeued then turn_dequeued(event)
|
|
263
269
|
when :turn_started
|
|
264
270
|
@status.take_event(event)
|
|
265
271
|
start_turn(event)
|
|
@@ -419,6 +425,8 @@ module Samagotchi
|
|
|
419
425
|
show_stats
|
|
420
426
|
elsif local == :recap
|
|
421
427
|
show_recap
|
|
428
|
+
elsif local == :unqueue
|
|
429
|
+
unqueue_newest
|
|
422
430
|
elsif route.kind == :command
|
|
423
431
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
424
432
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
@@ -620,6 +628,58 @@ module Samagotchi
|
|
|
620
628
|
QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
|
|
621
629
|
# /queue to a worker from before queueing, which would steer it.
|
|
622
630
|
QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
|
|
631
|
+
UNQUEUE_NEEDS_NEWER_WORKER = "(/unqueue needs a newer worker: restart it with chi sessions restart)"
|
|
632
|
+
NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
|
|
633
|
+
UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
|
|
634
|
+
"unknown_enqueued_id" => "(not removed: it is already gone)",
|
|
635
|
+
"not_queued" => "(not removed: it goes in at the next step)" }.freeze
|
|
636
|
+
|
|
637
|
+
# /unqueue: take back the newest prompt this run queued for after the
|
|
638
|
+
# turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
|
|
639
|
+
# to every UI; here the reply says what became of it. A worker whose
|
|
640
|
+
# sidecar lacks "unqueue" is not asked.
|
|
641
|
+
def unqueue_newest
|
|
642
|
+
return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) unless worker_unqueues?
|
|
643
|
+
|
|
644
|
+
id = @my_queued.last
|
|
645
|
+
return @screen.commit(NOTHING_QUEUED) unless id
|
|
646
|
+
|
|
647
|
+
reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
|
|
648
|
+
error = reply.json&.fetch("error", nil)
|
|
649
|
+
return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) if reply.status == 404 && error == "not_found"
|
|
650
|
+
|
|
651
|
+
# Removed, started or gone: no longer this run's to take back.
|
|
652
|
+
@my_queued.delete(id) if [200, 404, 409].include?(reply.status)
|
|
653
|
+
return unqueued(reply.json&.fetch("prompt", nil).to_s) if reply.status == 200
|
|
654
|
+
|
|
655
|
+
@screen.commit(UNQUEUE_REFUSED[error] || "could not remove the queued message (#{[reply.status, error].compact.join(" ")})")
|
|
656
|
+
rescue SystemCallError, IOError => e
|
|
657
|
+
@screen.commit("could not remove the queued message (#{worker_down(e)})")
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# The removal's line, and its text back in the input to change and
|
|
661
|
+
# send again (as a failed turn's prompt comes back, #restore_prompt):
|
|
662
|
+
# not over typed text, and not for lines from a pipe. Its images are
|
|
663
|
+
# not put back.
|
|
664
|
+
def unqueued(prompt)
|
|
665
|
+
@screen.commit("removed: #{first_line_clip(prompt)}")
|
|
666
|
+
return if @wait_at_eof || prompt.strip.empty?
|
|
667
|
+
|
|
668
|
+
@screen.commit("(the removed message is in the input history: ↑)") unless @reader&.prefill(prompt)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def first_line_clip(text, max = 60)
|
|
672
|
+
line = text.to_s.strip.lines.first.to_s.strip
|
|
673
|
+
line.length > max ? "#{line[0, max]}…" : line
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Whether the attached worker takes queued messages back (its sidecar
|
|
677
|
+
# names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
|
|
678
|
+
# answers (an older one's 404 not_found says so).
|
|
679
|
+
def worker_unqueues?
|
|
680
|
+
sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
|
|
681
|
+
sidecar.nil? || sidecar.features.include?(SessionInbox::UNQUEUE_FEATURE)
|
|
682
|
+
end
|
|
623
683
|
|
|
624
684
|
# A command queued for after the running turn (command_queued with
|
|
625
685
|
# waits): its line now (another client's) and a note; its command_ran
|
|
@@ -760,6 +820,7 @@ module Samagotchi
|
|
|
760
820
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
761
821
|
@sent_ids << enqueued_id if enqueued_id
|
|
762
822
|
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
823
|
+
@my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
|
|
763
824
|
note = sent_note(reply.json, delivery: delivery, images: images, running: running)
|
|
764
825
|
@screen.commit(note) if note
|
|
765
826
|
return
|
|
@@ -1095,6 +1156,12 @@ module Samagotchi
|
|
|
1095
1156
|
|
|
1096
1157
|
def render_snapshot(snapshot, reset:)
|
|
1097
1158
|
@view.finish_thinking_spinner
|
|
1159
|
+
queued = Array(snapshot[:queued])
|
|
1160
|
+
known_queued = @my_queued.to_set
|
|
1161
|
+
@my_queued = queued.filter_map do |entry|
|
|
1162
|
+
id = entry[:enqueued_id]
|
|
1163
|
+
id if id && (known_queued.include?(id) || (entry.key?(:client_id) && own?(entry[:client_id])))
|
|
1164
|
+
end
|
|
1098
1165
|
# A new worker's first snapshot is the session as it now stands.
|
|
1099
1166
|
moved = @worker_changed
|
|
1100
1167
|
reset ||= moved
|
|
@@ -1131,7 +1198,7 @@ module Samagotchi
|
|
|
1131
1198
|
# A question between turns (the step-limit one) belongs to no turn.
|
|
1132
1199
|
ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
|
|
1133
1200
|
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
1134
|
-
|
|
1201
|
+
queued.each do |entry|
|
|
1135
1202
|
next if own?(entry[:client_id])
|
|
1136
1203
|
|
|
1137
1204
|
@shown_enqueued << entry[:enqueued_id]
|
|
@@ -1453,10 +1520,31 @@ module Samagotchi
|
|
|
1453
1520
|
@screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
|
|
1454
1521
|
end
|
|
1455
1522
|
|
|
1523
|
+
# A queued prompt was taken back before it ran (the web's ×, /unqueue
|
|
1524
|
+
# in a terminal). One of this run's (wait_at_eof) will have no turn:
|
|
1525
|
+
# it ends the wait, as a failed one does. Another client's shown
|
|
1526
|
+
# line says so.
|
|
1527
|
+
def turn_dequeued(event)
|
|
1528
|
+
id = event[:enqueued_id]
|
|
1529
|
+
@my_queued.delete(id)
|
|
1530
|
+
if @open_ids.delete?(id)
|
|
1531
|
+
if own?(event[:client_id])
|
|
1532
|
+
@own_empty = true
|
|
1533
|
+
@screen.commit("(removed before it ran)")
|
|
1534
|
+
else
|
|
1535
|
+
@own_failed = true
|
|
1536
|
+
@screen.commit("(your queued message was removed before it ran)")
|
|
1537
|
+
end
|
|
1538
|
+
elsif @shown_enqueued.delete?(id)
|
|
1539
|
+
@screen.commit("(a queued message was removed)")
|
|
1540
|
+
end
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1456
1543
|
def start_turn(event)
|
|
1457
1544
|
@running = true
|
|
1458
1545
|
@joined_mid_turn = false
|
|
1459
1546
|
origin = event[:origin] || {}
|
|
1547
|
+
@my_queued.delete(origin[:enqueued_id])
|
|
1460
1548
|
if event[:prompt].nil?
|
|
1461
1549
|
# A continue turn (after the offer's yes) has no prompt to show; a
|
|
1462
1550
|
# reminder turn shows its reminders (:reminder_injected).
|
|
@@ -67,6 +67,8 @@ module Samagotchi
|
|
|
67
67
|
COMMAND_QUEUED = "(queued: runs after this turn)"
|
|
68
68
|
# /detach is the attached terminal's; the REPL owns its session.
|
|
69
69
|
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
70
|
+
# /unqueue is the attached terminal's (its worker's queued input files).
|
|
71
|
+
REPL_UNQUEUE_NOTE = "(/unqueue is the attached terminal's; here a line queued with /queue TEXT runs after the turn)"
|
|
70
72
|
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
71
73
|
|
|
72
74
|
# Raised when another process (a `chi web` worker or another chi) owns the
|
|
@@ -100,12 +102,16 @@ module Samagotchi
|
|
|
100
102
|
# @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
|
|
101
103
|
# session's toolkit (config's toolkit.default without); a resumed one
|
|
102
104
|
# keeps its own, with a line naming +toolkit_flags+ (the flags given)
|
|
105
|
+
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
106
|
+
# a new session's own; a resumed one keeps its own (--set is in
|
|
107
|
+
# +toolkit_flags+ then)
|
|
103
108
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
104
109
|
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
105
|
-
appended_prompt_files: [], toolkit: nil, toolkit_flags: [])
|
|
110
|
+
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {})
|
|
106
111
|
@scratch = scratch
|
|
107
112
|
@toolkit = toolkit
|
|
108
113
|
@toolkit_flags = toolkit_flags
|
|
114
|
+
@bundle_settings = bundle_settings
|
|
109
115
|
@thinking_word = thinking
|
|
110
116
|
@prompt = prompt
|
|
111
117
|
@llm_context_words = llm_context
|
|
@@ -274,7 +280,8 @@ module Samagotchi
|
|
|
274
280
|
preloaded_memory_names: @requested_memories,
|
|
275
281
|
muted_memory_names: @muted_memory_names,
|
|
276
282
|
scratch: @scratch,
|
|
277
|
-
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured
|
|
283
|
+
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
|
|
284
|
+
bundle_settings: @bundle_settings)
|
|
278
285
|
))
|
|
279
286
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
280
287
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -1154,6 +1161,7 @@ module Samagotchi
|
|
|
1154
1161
|
case local_command(input)
|
|
1155
1162
|
when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
|
|
1156
1163
|
when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
|
|
1164
|
+
when :unqueue then @surface.commit(REPL_UNQUEUE_NOTE)
|
|
1157
1165
|
else return false
|
|
1158
1166
|
end
|
|
1159
1167
|
true
|
|
@@ -11,6 +11,7 @@ require_relative "../peer_sources"
|
|
|
11
11
|
require_relative "../toolkit"
|
|
12
12
|
require_relative "../guardrails/gate"
|
|
13
13
|
require_relative "../memory_bundle/manifest"
|
|
14
|
+
require_relative "../session_setup"
|
|
14
15
|
require_relative "sandbox"
|
|
15
16
|
require_relative "recorder"
|
|
16
17
|
require_relative "host"
|
|
@@ -74,11 +75,15 @@ module Samagotchi
|
|
|
74
75
|
# @param dir [String] the bundle's folder (its manifest.yml)
|
|
75
76
|
# @param settings [Hash] config.yml `bundles: <name>:`
|
|
76
77
|
# @param host [Hash] session values (Recorder::DEFAULTS' keys)
|
|
78
|
+
# @param session_settings [Hash] the session's own values for the
|
|
79
|
+
# bundle (chi --set), keys its manifest lists in session_settings:;
|
|
80
|
+
# ctx.settings lays them over +settings+ (Plugin::Context#settings),
|
|
81
|
+
# initialize(settings) doesn't see them
|
|
77
82
|
# @param sandbox [Sandbox, nil] default Sandbox.current, else one of
|
|
78
83
|
# its own, left at #shutdown
|
|
79
84
|
# @return [Bundle]
|
|
80
|
-
def load(dir, settings: {}, host: {}, sandbox: nil)
|
|
81
|
-
new(dir, settings: settings, host: host, sandbox: sandbox).tap(&:load!)
|
|
85
|
+
def load(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
|
|
86
|
+
new(dir, settings: settings, host: host, session_settings: session_settings, sandbox: sandbox).tap(&:load!)
|
|
82
87
|
end
|
|
83
88
|
|
|
84
89
|
# A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
|
|
@@ -118,14 +123,16 @@ module Samagotchi
|
|
|
118
123
|
# @return [Integer] how often chi was told the tools changed
|
|
119
124
|
attr_reader :tools_changes
|
|
120
125
|
|
|
121
|
-
def initialize(dir, settings: {}, host: {}, sandbox: nil)
|
|
126
|
+
def initialize(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
|
|
122
127
|
@dir = File.expand_path(dir)
|
|
123
128
|
@manifest = MemoryBundle::Manifest.read(dir: @dir)
|
|
124
129
|
@name = @manifest.name
|
|
125
130
|
@settings = stringify(settings || {})
|
|
131
|
+
@session_moves = 1
|
|
132
|
+
own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
|
|
126
133
|
@own_sandbox = sandbox.nil? && Sandbox.current.nil?
|
|
127
134
|
@sandbox = sandbox || Sandbox.current || Sandbox.enter
|
|
128
|
-
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host)
|
|
135
|
+
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host, **own)
|
|
129
136
|
@sessions = Sessions.new
|
|
130
137
|
@http = FakeHttp.new
|
|
131
138
|
@inits = []
|
|
@@ -309,7 +316,16 @@ module Samagotchi
|
|
|
309
316
|
end
|
|
310
317
|
|
|
311
318
|
# Change the session's values (cwd:, messages:, running: …).
|
|
312
|
-
|
|
319
|
+
# +session_settings+ changes the session's own values for the bundle
|
|
320
|
+
# (as Bundle.load's) and moves it to a new session id
|
|
321
|
+
# ("test-session-2", or the session_id: given beside it): chi never
|
|
322
|
+
# changes them within a session, and ctx.settings is built once per
|
|
323
|
+
# session id.
|
|
324
|
+
def set(session_settings: nil, **values)
|
|
325
|
+
unless session_settings.nil?
|
|
326
|
+
values[:setup] = setup_with(session_settings, @recorder[:setup])
|
|
327
|
+
values[:session_id] ||= "#{@recorder[:session_id] || "test-session"}-#{@session_moves += 1}"
|
|
328
|
+
end
|
|
313
329
|
@recorder.set(**values)
|
|
314
330
|
self
|
|
315
331
|
end
|
|
@@ -363,6 +379,16 @@ module Samagotchi
|
|
|
363
379
|
result
|
|
364
380
|
end
|
|
365
381
|
|
|
382
|
+
# +setup+ (the recorder's, nil for none) with +session_settings+ as
|
|
383
|
+
# this bundle's own values, checked against the manifest's
|
|
384
|
+
# session_settings: as chi --set checks them.
|
|
385
|
+
# @raise [ArgumentError] a key the manifest doesn't list
|
|
386
|
+
def setup_with(session_settings, setup)
|
|
387
|
+
values = { @name => stringify(session_settings) }
|
|
388
|
+
BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings }, flag: "session_settings")
|
|
389
|
+
(setup || SessionSetup.new).with(bundle_settings: values)
|
|
390
|
+
end
|
|
391
|
+
|
|
366
392
|
def stringify(value)
|
|
367
393
|
case value
|
|
368
394
|
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|