samagotchi 0.51.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -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 +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +5 -1
  11. data/docs/testing.md +6 -1
  12. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  13. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  14. data/lib/samagotchi/bridge.rb +10 -13
  15. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/children_status.rb +1 -1
  19. data/lib/samagotchi/commands/registry.rb +2 -1
  20. data/lib/samagotchi/config.rb +50 -182
  21. data/lib/samagotchi/config_text_edit.rb +8 -32
  22. data/lib/samagotchi/context_sources.rb +0 -7
  23. data/lib/samagotchi/context_window.rb +2 -2
  24. data/lib/samagotchi/continue_offer.rb +5 -4
  25. data/lib/samagotchi/cut_policy.rb +0 -1
  26. data/lib/samagotchi/delivery.rb +23 -0
  27. data/lib/samagotchi/engine.rb +48 -17
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/host_config.rb +183 -0
  30. data/lib/samagotchi/host_registry.rb +5 -10
  31. data/lib/samagotchi/last_turn.rb +48 -0
  32. data/lib/samagotchi/live_versions.rb +0 -6
  33. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  34. data/lib/samagotchi/llm_context_override.rb +3 -22
  35. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  36. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  37. data/lib/samagotchi/model_profile.rb +4 -4
  38. data/lib/samagotchi/model_settings.rb +46 -0
  39. data/lib/samagotchi/plugin/context.rb +3 -3
  40. data/lib/samagotchi/plugin/sessions.rb +2 -2
  41. data/lib/samagotchi/queued_command.rb +35 -0
  42. data/lib/samagotchi/reminder_store.rb +0 -27
  43. data/lib/samagotchi/reply_wait.rb +11 -13
  44. data/lib/samagotchi/sampling_settings.rb +4 -4
  45. data/lib/samagotchi/self_report.rb +8 -5
  46. data/lib/samagotchi/send_command.rb +37 -19
  47. data/lib/samagotchi/session.rb +45 -24
  48. data/lib/samagotchi/session_commands.rb +36 -7
  49. data/lib/samagotchi/session_inbox.rb +18 -9
  50. data/lib/samagotchi/session_manager.rb +8 -7
  51. data/lib/samagotchi/session_setup.rb +30 -0
  52. data/lib/samagotchi/steer_cut.rb +9 -2
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  55. data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
  56. data/lib/samagotchi/terminal_ui.rb +8 -3
  57. data/lib/samagotchi/thinking.rb +67 -7
  58. data/lib/samagotchi/thinking_command.rb +72 -0
  59. data/lib/samagotchi/tools/builtins.rb +7 -2
  60. data/lib/samagotchi/tools/peers.rb +3 -2
  61. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/vision_support.rb +4 -3
  64. data/lib/samagotchi/web/app.rb +54 -6
  65. data/lib/samagotchi/web/public/app.js +133 -20
  66. data/lib/samagotchi/web/public/data.js +8 -4
  67. data/lib/samagotchi/web/public/format.js +8 -4
  68. data/lib/samagotchi/web/public/index.html +8 -5
  69. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  70. data/lib/samagotchi/web/public/model_pick.js +3 -1
  71. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  72. data/lib/samagotchi/web/public/timing.js +15 -0
  73. data/lib/samagotchi/web/public/turn_events.js +23 -0
  74. data/lib/samagotchi/web/public/turn_view.js +328 -310
  75. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  76. data/lib/samagotchi/web/session_summary.rb +1 -1
  77. data/lib/samagotchi/worker.rb +43 -42
  78. data/lib/samagotchi/worker_inbound.rb +5 -5
  79. data/lib/samagotchi/worker_wakes.rb +0 -2
  80. data/lib/samagotchi/yaml_lines.rb +72 -0
  81. metadata +11 -1
@@ -183,20 +183,29 @@ module Samagotchi
183
183
  Dir.glob(File.join(input_dir, "*.json"))
184
184
  end
185
185
 
186
- # @return [Array(String, Hash|nil, Boolean, Array<Hash>, String|nil)] a
187
- # claimed input file's prompt, origin ({client_id:, enqueued_id:},
188
- # nil when it names no sender), whether its turn runs with the raised
189
- # iteration limit (--no-interrupt), its image refs ({file:, name:}),
190
- # and its delivery ("queue" or nil; a file without the key is one an
191
- # older chi wrote, so nil)
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
192
199
  def self.read_input(claimed_file)
193
200
  data = JSON.parse(File.read(claimed_file).to_s)
201
+ return UNREADABLE_INPUT unless data.is_a?(Hash)
202
+
194
203
  origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
195
204
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
196
- [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images,
197
- Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil]
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)
198
207
  rescue JSON::ParserError
199
- [nil, nil, false, [], nil]
208
+ UNREADABLE_INPUT
200
209
  end
201
210
 
202
211
  # Whether an unclaimed input file carries images (a mid-turn drain
@@ -14,6 +14,7 @@ require_relative "session"
14
14
  require_relative "session_inbox"
15
15
  require_relative "session_metrics"
16
16
  require_relative "llm_context_strategy"
17
+ require_relative "session_setup"
17
18
  require_relative "host_registry"
18
19
  require_relative "utf8_default"
19
20
  require_relative "turn_note"
@@ -163,8 +164,8 @@ module Samagotchi
163
164
  # @param images_from [String, nil] the session dir the seed's images are in
164
165
  # @param title [String, nil] what the lists show before the first turn
165
166
  # (the prompt's preview by default, else the seed's first user message)
166
- # @param llm_context [LLMContextOverride, nil] the session's own
167
- # 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)
168
169
  # @param continues [String, nil] the session this one continues (a
169
170
  # session field; #continue_session)
170
171
  # @param note [String, nil] a context note the session starts with,
@@ -176,7 +177,7 @@ module Samagotchi
176
177
  # and #model_warning: an id its host's saved list doesn't have
177
178
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
178
179
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
179
- title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
180
+ title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
180
181
  note_source: "chi", before_spawn: nil)
181
182
  sd = state_dir || Session.default_state_dir
182
183
  # The resolved ref is stored (a resumed session keeps its model when an
@@ -198,7 +199,7 @@ module Samagotchi
198
199
  parent_id: parent_id,
199
200
  messages: messages,
200
201
  delegate: delegate,
201
- llm_context: llm_context,
202
+ setup: setup,
202
203
  continues: continues
203
204
  )
204
205
  session.model_warning = model_warning
@@ -232,7 +233,7 @@ module Samagotchi
232
233
 
233
234
  # Start the next link of a chain: a new session that continues
234
235
  # +id_or_ref+ (Session#continues) in its folder, on its model (the name
235
- # 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),
236
237
  # starting with a context note from chi: the link and the previous
237
238
  # link's recap (SessionChain.note_text). The previous link is archived
238
239
  # first, with its finished delegates (archive_session's rules: a turn
@@ -363,7 +364,7 @@ module Samagotchi
363
364
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
364
365
  end
365
366
  link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
366
- 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),
367
368
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
368
369
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
369
370
  link.moved_children = move
@@ -515,7 +516,7 @@ module Samagotchi
515
516
 
516
517
  GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
517
518
  # Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
518
- 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
519
520
 
520
521
  # List all sessions, reading status from persisted session.json files.
521
522
  # +project_root+: only that project's sessions (nil: every session).
@@ -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
@@ -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:)
@@ -51,7 +58,7 @@ module Samagotchi
51
58
  def cut_for_steer(source, epoch: nil)
52
59
  return :off unless Steer.cuts?(source)
53
60
 
54
- after = Config.get("steer.cut_after").to_i
61
+ after = self.class.cut_after
55
62
  return :off unless after.positive?
56
63
 
57
64
  ctrl = @controller.call
@@ -104,7 +111,7 @@ module Samagotchi
104
111
  def recheck
105
112
  return unless @waiting.waiting?
106
113
 
107
- after = Config.get("steer.cut_after").to_i
114
+ after = self.class.cut_after
108
115
  return unless after.positive? && @phase.cuttable?(after)
109
116
 
110
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")
@@ -430,7 +430,7 @@ module Samagotchi
430
430
  # @return [nil]
431
431
  def submit_delivery(choice, text)
432
432
  return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
433
- return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue
433
+ return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue && @running
434
434
 
435
435
  # To an idle session it is an ordinary prompt: no delivery to send.
436
436
  send_prompt(text, delivery: @running ? Delivery::CUT : nil)
@@ -5,6 +5,7 @@ require_relative "../served_model"
5
5
  require_relative "../image_store"
6
6
  require_relative "../turn_note"
7
7
  require_relative "../steer"
8
+ require_relative "../steer_cut"
8
9
  require_relative "../memory_bundle/index_size"
9
10
  require_relative "../prompt_note"
10
11
 
@@ -99,7 +100,8 @@ module Samagotchi
99
100
  # line goes in at the running turn's next step boundary (nothing cuts
100
101
  # unless the message asked for it), /cut's cutting now or once the
101
102
  # thinking passes steer.cut_after, and /queue's waiting for the end.
102
- # Both TUIs print the same words (SteerCut#cut_for_steer's answer).
103
+ # Both TUIs and chi send --cut print the same words
104
+ # (SteerCut#cut_for_steer's answer).
103
105
  NEXT_STEP_NOTE = "(goes in at the next step)"
104
106
  CUT_NOW_NOTE = "(cut in now)"
105
107
  CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
@@ -115,14 +117,17 @@ module Samagotchi
115
117
 
116
118
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
117
119
  # @return [String] the dim line to show for a /cut
118
- def steer_cut_note(outcome)
120
+ def self.steer_cut_note(outcome)
119
121
  case outcome
120
122
  when :now then CUT_NOW_NOTE
121
- when :waits then format(CUT_WAITS_NOTE, Config.get("steer.cut_after").to_i)
123
+ when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
122
124
  else CUT_OFF_NOTE
123
125
  end
124
126
  end
125
127
 
128
+ # The same words, for a UI that includes the module.
129
+ def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
130
+
126
131
  # The choice a mid-turn line carries, as [:cut, "TEXT"] or
127
132
  # [:queue, "TEXT"]; nil for any other line. The text is "" when the
128
133
  # line named the choice without one.
@@ -591,6 +596,8 @@ module Samagotchi
591
596
  lines << "model notes: #{notes}" unless notes.empty?
592
597
  llm_context = llm_context_stats_text(snapshot[:llm_context])
593
598
  lines << "llm context: #{llm_context}" if llm_context
599
+ thinking = thinking_stats_text(snapshot[:thinking])
600
+ lines << "thinking: #{thinking}" if thinking
594
601
  if snapshot[:served_model]
595
602
  asked = snapshot[:served_model_for]
596
603
  differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
@@ -630,6 +637,17 @@ module Samagotchi
630
637
  "budget #{budget} (#{get.call(:budget_where)})"
631
638
  end
632
639
 
640
+ # "low (session)", "default" for /stats (Thinking::Explained#summary;
641
+ # symbol or string keys, the attached TUI's come as JSON); nil
642
+ # without one.
643
+ def thinking_stats_text(summary)
644
+ return nil unless summary.is_a?(Hash)
645
+
646
+ level = summary[:level] || summary["level"]
647
+ source = summary[:source] || summary["source"]
648
+ source ? "#{level} (#{source})" : level
649
+ end
650
+
633
651
  # ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
634
652
  # 212" for the tokens line; the counts a server didn't report are left
635
653
  # out (re-prefilled: SessionMetrics' reprefill_sum).
@@ -90,9 +90,12 @@ module Samagotchi
90
90
  # however the REPL ends, saves no memories and writes no recap
91
91
  # @param llm_context [Hash{Symbol => String}] --llm-context* as typed
92
92
  # (LLMContextOverride.update): set on the new or resumed session
93
+ # @param thinking [String, nil] --thinking as typed: the new or resumed
94
+ # session's own level (default unsets it)
93
95
  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,
94
- spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {})
96
+ spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil)
95
97
  @scratch = scratch
98
+ @thinking_word = thinking
96
99
  @prompt = prompt
97
100
  @llm_context_words = llm_context
98
101
  @default_model_name = ModelProfile.required_model_name(nil)
@@ -246,8 +249,10 @@ module Samagotchi
246
249
  ))
247
250
  # --llm-context*: the session's own values, over a resumed one's.
248
251
  # Saved at once on a resumed one: leaving before a turn keeps them.
249
- unless @llm_context_words.empty?
250
- session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words)
252
+ # --thinking: the session's own level, saved at once on a resumed one.
253
+ unless @llm_context_words.empty? && @thinking_word.nil?
254
+ session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words) unless @llm_context_words.empty?
255
+ session.thinking = Thinking.session_level(@thinking_word) if @thinking_word
251
256
  session.save if @resume_session
252
257
  end
253
258
  claim_session!(session.id) unless @owner_lock
@@ -10,7 +10,9 @@ module Samagotchi
10
10
  # low, medium, high the effort a chat host is asked for
11
11
  # default chi sends nothing: the provider's or template's own default
12
12
  #
13
- # Order (#resolve): --thinking / SAMAGOTCHI_THINKING_LEVEL, then
13
+ # Order (#resolve): the session's own level (/thinking, chi --thinking at
14
+ # start, chi send --new --thinking, the web), then the process default
15
+ # (chi web --thinking / SAMAGOTCHI_THINKING_LEVEL), then
14
16
  # models.<key>.thinking, hosts.<name>.thinking, thinking.level in
15
17
  # config.yml, then default.
16
18
  module Thinking
@@ -33,8 +35,39 @@ module Samagotchi
33
35
  # anything there (low/medium/high have no native knob).
34
36
  Native = Data.define(:system_token, :prefill, :honoured)
35
37
 
38
+ # The source a session's own level reads as.
39
+ SESSION_SOURCE = "session"
40
+
41
+ # A level and where it came from (#explain): +source+ is "session",
42
+ # "--thinking", "SAMAGOTCHI_THINKING_LEVEL", "models: qwen",
43
+ # "hosts.work" or "thinking.level", nil for the default; +own+ is the
44
+ # session's own level, nil without one. What /thinking, /model,
45
+ # /stats, the snapshots and the web read.
46
+ Explained = Data.define(:level, :source, :own) do
47
+ # "low (session)", "default" with no source.
48
+ def label = source ? "#{level} (#{source})" : level.to_s
49
+
50
+ # The plain form the snapshots, the worker's command_ran event and
51
+ # the web read.
52
+ # @return [Hash] symbol keys
53
+ def summary = { level: level.to_s, source: source, own: own&.to_s }
54
+ end
55
+
36
56
  module_function
37
57
 
58
+ # A session's own level from its file or a command word: :off, :low,
59
+ # :medium or :high; nil for anything else, default included (unset:
60
+ # the session follows the process default and the model). Never warns:
61
+ # a session file keeps a value it can't read as written.
62
+ # @return [Symbol, nil]
63
+ def session_level(value)
64
+ return value if LEVELS.include?(value) && value != DEFAULT
65
+ return nil unless value.is_a?(String)
66
+
67
+ level = value.strip.downcase.to_sym
68
+ LEVELS.include?(level) && level != DEFAULT ? level : nil
69
+ end
70
+
38
71
  # A configured value as a level; nil when unset or not a level (that
39
72
  # warns once, naming +where+). YAML reads an unquoted `off` as false,
40
73
  # which is :off; true (`on`) isn't a level.
@@ -51,13 +84,17 @@ module Samagotchi
51
84
  nil
52
85
  end
53
86
 
54
- # The level for +target+ and where it came from ("--thinking",
55
- # "models: qwen", "hosts.work", "thinking.level"; nil for the default).
87
+ # The level for +target+ and where it came from ("session",
88
+ # "--thinking", "models: qwen", "hosts.work", "thinking.level"; nil for
89
+ # the default).
56
90
  # @param target [HostRegistry::ModelTarget]
57
91
  # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
58
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
92
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
93
+ # @param session [Symbol, nil] the session's own level (Session#thinking)
59
94
  # @return [Array(Symbol, String|nil)]
60
- def resolve(target, names:, models: nil)
95
+ def resolve(target, names:, models: nil, session: nil)
96
+ return [session, SESSION_SOURCE] if session
97
+
61
98
  global, origin = global_level
62
99
  return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
63
100
 
@@ -66,8 +103,8 @@ module Samagotchi
66
103
  rescue StandardError
67
104
  {}
68
105
  end
69
- key, value = ConfigFile.model_setting(names, :thinking, models: models)
70
- return [value, "models: #{key}"] if key
106
+ setting = ConfigFile.model_setting(names, :thinking, models: models)
107
+ return [setting.value, "models: #{setting.key}"] if setting
71
108
 
72
109
  host = target.entry.thinking
73
110
  return [host, "hosts.#{target.entry.name}"] if host
@@ -76,6 +113,13 @@ module Samagotchi
76
113
  [DEFAULT, nil]
77
114
  end
78
115
 
116
+ # #resolve, as an Explained with the session's own level beside it.
117
+ # @return [Explained]
118
+ def explain(target, names:, models: nil, session: nil)
119
+ level, source = resolve(target, names: names, models: models, session: session)
120
+ Explained.new(level: level, source: source, own: session)
121
+ end
122
+
79
123
  # thinking.level as a level and its origin (:cli, :env, :file), or nil.
80
124
  def global_level
81
125
  raw, origin = Config.get_with_origin("thinking.level")
@@ -88,6 +132,22 @@ module Samagotchi
88
132
  nil
89
133
  end
90
134
 
135
+ # What a level change costs the prompt cache on +target+ (/thinking's
136
+ # note; a confirm step may ask before the costly ones):
137
+ # :full the native Gemma 4 prompt starts with <|think|>, so the
138
+ # whole prompt is read again;
139
+ # :provider a chat host gets other request fields: a local llama.cpp
140
+ # or Splash server keeps its cache, a hosted API may not;
141
+ # :tail native Qwen and the rest: only the tail changes.
142
+ # @param target [HostRegistry::ModelTarget]
143
+ # @param profile [String, nil] the native prompt profile's name
144
+ # @return [Symbol]
145
+ def cache_cost(target, profile: nil)
146
+ return :provider if target.entry&.chat?
147
+
148
+ profile == "gemma4" ? :full : :tail
149
+ end
150
+
91
151
  # Request fields for an OpenAI-style chat host. off sends both switches
92
152
  # (the template's enable_thinking and the OpenAI-style reasoning_effort
93
153
  # "none"): llama.cpp, Splash and OpenRouter each honour one of them.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "thinking"
4
+
5
+ module Samagotchi
6
+ # /thinking, the session's own thinking level (Session#thinking): with no
7
+ # argument, the level the next turn runs at and where it came from;
8
+ # `/thinking off|low|medium|high` sets the session's own, `/thinking
9
+ # default` unsets it (the session follows the process default, the
10
+ # model, its host, then thinking.level). A change takes effect at the
11
+ # next turn's start (the REPL and the worker run it between turns), and
12
+ # the reply says what it costs the prompt cache (Thinking.cache_cost).
13
+ class ThinkingCommand
14
+ NAME = "/thinking"
15
+ SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
16
+ USAGE = "usage: #{NAME} [#{Thinking::LEVELS.join("|")}]".freeze
17
+
18
+ COST_NOTES = {
19
+ full: "On this model the whole prompt is read again (Gemma's <|think|> starts it).",
20
+ provider: "The request's thinking fields change: a local llama.cpp or Splash server keeps its cache; " \
21
+ "hosted APIs (OpenRouter → Claude, OpenAI) may restart theirs.",
22
+ tail: "Only the prompt's tail changes: the cache keeps the rest."
23
+ }.freeze
24
+
25
+ # @param engine [Engine] #thinking_explained, #thinking_override(=),
26
+ # #session, #thinking_cache_cost
27
+ # @param save [#call] saves the session after a change
28
+ def initialize(engine:, save:)
29
+ @engine = engine
30
+ @save = save
31
+ end
32
+
33
+ # @param args [String] what follows /thinking
34
+ # @return [Array(String, Boolean)] the reply and whether the level changed
35
+ # @raise [ArgumentError] a word that isn't a level (with USAGE)
36
+ def run(args)
37
+ word = args.to_s.strip.downcase
38
+ return [listing(@engine.thinking_explained), false] if word.empty?
39
+
40
+ level = word.to_sym
41
+ raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
42
+
43
+ session = @engine.session or raise ArgumentError, "no session yet"
44
+ before = @engine.thinking_explained
45
+ @engine.thinking_override = SETTABLE.include?(level) ? level : nil
46
+ @save.call(session)
47
+ after = @engine.thinking_explained
48
+ [[listing(after), *notes(before, after)].join("\n"), true]
49
+ end
50
+
51
+ # The level, where it came from, and what the session sets itself.
52
+ def listing(explained)
53
+ return "thinking: can't be resolved for this model" unless explained
54
+
55
+ lines = ["thinking: #{explained.label}"]
56
+ unless explained.own
57
+ lines << " this session's own: none (follows chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, " \
58
+ "its host, then thinking.level)"
59
+ end
60
+ lines.join("\n")
61
+ end
62
+
63
+ private
64
+
65
+ def notes(before, after)
66
+ return [] unless before && after
67
+ return ["(no change to what the next turn runs under)"] if before.level == after.level
68
+
69
+ ["From the next turn's start (a running turn keeps its own).", COST_NOTES[@engine.thinking_cache_cost]].compact
70
+ end
71
+ end
72
+ end
@@ -119,15 +119,19 @@ module Samagotchi
119
119
  # wrapper. The session's id, "chi" without one. And the model the
120
120
  # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
121
121
  # /model), which chi self reports; unset when unknown, so a value
122
- # inherited from an outer session never passes for this one. A
122
+ # inherited from an outer session never passes for this one. So too
123
+ # the session's own thinking level (SAMAGOTCHI_SESSION_THINKING, unset
124
+ # without one), which chi self puts first. A
123
125
  # worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
124
126
  # is unset: it was that run's, not the default of a chi run here.
125
127
  def parent_env(kctx, env: ENV)
126
128
  peers = kctx.peers
127
129
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
128
130
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
131
+ thinking = peers.respond_to?(:thinking) ? peers.thinking&.to_s : nil
129
132
  { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
130
- SESSION_MODEL_ENV => model.empty? ? nil : model }.merge(cli_model_unset(env))
133
+ SESSION_MODEL_ENV => model.empty? ? nil : model,
134
+ SESSION_THINKING_ENV => thinking }.merge(cli_model_unset(env))
131
135
  end
132
136
 
133
137
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -139,6 +143,7 @@ module Samagotchi
139
143
  end
140
144
 
141
145
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
146
+ SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
142
147
 
143
148
  # task_create's parent_env, less the unsets of a variable the model
144
149
  # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
@@ -8,10 +8,11 @@ module Samagotchi
8
8
  # canceled (a waiting tool returns on it), and the question flow a
9
9
  # delegate's approval is relayed through (relay: nil when this session
10
10
  # can't host one), and the model the session runs on (model_ref: the
11
- # resolved ref, live after /model). Engine hands KernelLoop one that follows its current
11
+ # resolved ref, live after /model), and the session's own thinking level
12
+ # (thinking: nil without one). Engine hands KernelLoop one that follows its current
12
13
  # session (Engine::PeerView); this plain one is for specs and a kernel
13
14
  # without an Engine.
14
- Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
15
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, keyword_init: true) do
15
16
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
16
17
  def cancelled?
17
18
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -22,7 +22,7 @@ module Samagotchi
22
22
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
23
23
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
24
24
  # Set by chi (Builtins.parent_env); the model's env can't change it.
25
- MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
25
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
26
26
  # task_wait and task_get add it to a task the user stopped (the web's
27
27
  # stop-task button), so the model doesn't rerun the job.
28
28
  # A record whose pid isn't a process group chi started (missing, 0, 1,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.51.0"
4
+ VERSION = "0.52.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # @param target [HostRegistry::ModelTarget]
25
25
  # @param profile [ModelProfile, nil] the model's prompt profile (native)
26
26
  # @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
27
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
27
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
28
28
  # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
29
29
  # @return [Answer]
30
30
  def self.for(target, names:, profile: nil, adapter: nil, models: nil)
@@ -51,8 +51,9 @@ module Samagotchi
51
51
  rescue StandardError
52
52
  {}
53
53
  end
54
- key, value = ConfigFile.model_setting(names, :vision, models: models)
55
- return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
54
+ if (setting = ConfigFile.model_setting(names, :vision, models: models))
55
+ return Answer.new(value: setting.value, reason: setting.value ? nil : "models: #{setting.key} sets vision: false")
56
+ end
56
57
  return nil if entry.vision.nil?
57
58
 
58
59
  Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")