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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/bin/chi +27 -2
  4. data/docs/architecture.md +10 -1
  5. data/docs/cli.md +17 -1
  6. data/docs/configuration.md +2 -0
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +3 -2
  10. data/docs/plugins.md +31 -5
  11. data/docs/sessions.md +15 -2
  12. data/docs/sub-agent.md +5 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  14. data/lib/samagotchi/bridge.rb +54 -1
  15. data/lib/samagotchi/bridge_client.rb +12 -0
  16. data/lib/samagotchi/bundle_session_settings.rb +212 -0
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -2
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +12 -5
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/engine.rb +1 -1
  21. data/lib/samagotchi/kernel_loop.rb +5 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  23. data/lib/samagotchi/log_subscriber.rb +1 -0
  24. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  25. data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
  26. data/lib/samagotchi/memory_bundle/installer.rb +2 -1
  27. data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
  28. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  29. data/lib/samagotchi/parent_report.rb +5 -2
  30. data/lib/samagotchi/plugin/context.rb +36 -2
  31. data/lib/samagotchi/reply_wait.rb +8 -1
  32. data/lib/samagotchi/send_command.rb +24 -7
  33. data/lib/samagotchi/session.rb +16 -1
  34. data/lib/samagotchi/session_commands.rb +7 -4
  35. data/lib/samagotchi/session_inbox.rb +68 -0
  36. data/lib/samagotchi/session_setup.rb +14 -6
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
  39. data/lib/samagotchi/terminal_ui.rb +10 -2
  40. data/lib/samagotchi/testing/bundle.rb +31 -5
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +41 -4
  43. data/lib/samagotchi/web/public/app.js +17 -0
  44. data/lib/samagotchi/web/public/bubbles.js +9 -0
  45. data/lib/samagotchi/web/public/commands.js +1 -0
  46. data/lib/samagotchi/web/public/data.js +11 -0
  47. data/lib/samagotchi/web/public/index.html +9 -0
  48. data/lib/samagotchi/web/public/stage_view.js +6 -0
  49. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  50. data/lib/samagotchi/web/public/turn_events.js +4 -0
  51. data/lib/samagotchi/web/public/turn_html.js +9 -1
  52. data/lib/samagotchi/web/public/unqueue.js +56 -0
  53. 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 toolkit,
69
- with a note carrying its recap; it is archived.
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 and " \
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
@@ -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. /queue is a real after-the-turn list only in the local
127
- # REPL (the attached TUI's worker doesn't skip queued files yet), so
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: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
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) do
25
- def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
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
- Array(snapshot[:queued]).each do |entry|
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
- def set(**values)
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)] }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.83.0"
4
+ VERSION = "0.85.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end