samagotchi 0.50.0 → 0.52.0

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