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
@@ -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
 
@@ -31,13 +32,17 @@ module Samagotchi
31
32
 
32
33
  # What a tool line says the call did: the model's description of a
33
34
  # command (": List the specs") in place of its cut params, else the
34
- # params, dim.
35
+ # params, dim; then, dim, the name the model called it by when that was
36
+ # an alias's (" (called as bash)").
35
37
  def tool_params_suffix(activity)
36
38
  description = activity[:description].to_s.strip
37
- return ": #{description}" unless description.empty?
38
-
39
39
  params = activity[:params].to_s.strip
40
- params.empty? ? "" : " #{paint(params, 90)}"
40
+ suffix = if !description.empty? then ": #{description}"
41
+ elsif !params.empty? then " #{paint(params, 90)}"
42
+ else ""
43
+ end
44
+ called_as = activity[:called_as].to_s
45
+ called_as.empty? ? suffix : "#{suffix}#{paint(" (called as #{called_as})", 90)}"
41
46
  end
42
47
 
43
48
  # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
@@ -91,6 +96,49 @@ module Samagotchi
91
96
  paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
92
97
  end
93
98
 
99
+ # What a line submitted at the open prompt says became of it: a plain
100
+ # line goes in at the running turn's next step boundary (nothing cuts
101
+ # unless the message asked for it), /cut's cutting now or once the
102
+ # thinking passes steer.cut_after, and /queue's waiting for the end.
103
+ # Both TUIs and chi send --cut print the same words
104
+ # (SteerCut#cut_for_steer's answer).
105
+ NEXT_STEP_NOTE = "(goes in at the next step)"
106
+ CUT_NOW_NOTE = "(cut in now)"
107
+ CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
108
+ CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
109
+ STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
110
+ # The UIs' own words for a delivery, listed for Tab and /help only
111
+ # (a local: entry, so no command or bundle may use the names).
112
+ CUT_COMMAND = "/cut"
113
+ QUEUE_COMMAND = "/queue"
114
+ # The word, then any whitespace and the text (as the registry's
115
+ # entries match them, so a line Tab or /help knows is never a prompt).
116
+ STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
117
+
118
+ # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
119
+ # @return [String] the dim line to show for a /cut
120
+ def self.steer_cut_note(outcome)
121
+ case outcome
122
+ when :now then CUT_NOW_NOTE
123
+ when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
124
+ else CUT_OFF_NOTE
125
+ end
126
+ end
127
+
128
+ # The same words, for a UI that includes the module.
129
+ def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
130
+
131
+ # The choice a mid-turn line carries, as [:cut, "TEXT"] or
132
+ # [:queue, "TEXT"]; nil for any other line. The text is "" when the
133
+ # line named the choice without one.
134
+ # @param line [String, nil]
135
+ # @return [Array(Symbol, String), nil]
136
+ def steer_choice(line)
137
+ match = STEER_CHOICE.match(line.to_s.strip) or return nil
138
+
139
+ [match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
140
+ end
141
+
94
142
  # "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
95
143
  # off)": a batch of LLM context edits went in (LLMContextNotice builds
96
144
  # the line; the web prints the same), dim like the retry row.
@@ -516,6 +564,10 @@ module Samagotchi
516
564
  by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
517
565
  lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
518
566
  end
567
+ unless snapshot[:tool_calls_aliased].to_a.empty?
568
+ aliased = snapshot[:tool_calls_aliased].sort_by { |_k, v| -v }
569
+ lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
570
+ end
519
571
  lines << "iterations: #{snapshot[:iterations_total]}"
520
572
  # Summed over every request: each prompt is sent in full again.
521
573
  lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
@@ -544,6 +596,8 @@ module Samagotchi
544
596
  lines << "model notes: #{notes}" unless notes.empty?
545
597
  llm_context = llm_context_stats_text(snapshot[:llm_context])
546
598
  lines << "llm context: #{llm_context}" if llm_context
599
+ thinking = thinking_stats_text(snapshot[:thinking])
600
+ lines << "thinking: #{thinking}" if thinking
547
601
  if snapshot[:served_model]
548
602
  asked = snapshot[:served_model_for]
549
603
  differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
@@ -583,6 +637,17 @@ module Samagotchi
583
637
  "budget #{budget} (#{get.call(:budget_where)})"
584
638
  end
585
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
+
586
651
  # ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
587
652
  # 212" for the tokens line; the counts a server didn't report are left
588
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
@@ -752,6 +757,14 @@ module Samagotchi
752
757
  def run_input_line(session, input)
753
758
  return if input.empty?
754
759
 
760
+ # /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
761
+ # the prompt both run the text as a normal turn, as their usage says.
762
+ if (choice = steer_choice(input))
763
+ return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
764
+
765
+ input = choice.last
766
+ end
767
+
755
768
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
756
769
  # an anytime command's cards print as it shows them (btw's
757
770
  # "thinking…" before the answer).
@@ -1083,6 +1096,10 @@ module Samagotchi
1083
1096
  # plugin's /btw, /model alone) starts now on its own thread; one that
1084
1097
  # runs after the turn (/model X, !cmd) waits in the queue, in arrival
1085
1098
  # order with the other lines that wait; others go back in the prompt.
1099
+ #
1100
+ # A plain line goes in at the running turn's next step boundary and
1101
+ # never cuts; /cut TEXT asks for a cut, /queue TEXT waits for the turn
1102
+ # to end and runs as a turn of its own.
1086
1103
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1087
1104
  def steer_line(line)
1088
1105
  local = local_command(line) unless line.nil?
@@ -1091,6 +1108,12 @@ module Samagotchi
1091
1108
  return detach_note if local == :detach
1092
1109
  return false if @active_cancel_controller&.cancelled?
1093
1110
 
1111
+ # Before every command check: /cut and /queue are the UIs' own words
1112
+ # for a delivery (they are listed for Tab and /help, never run).
1113
+ if (choice = steer_choice(line))
1114
+ return submit_steer_choice(*choice)
1115
+ end
1116
+
1094
1117
  policy = command_registry.mid_turn(line)
1095
1118
  return start_anytime_command(line) if policy == :anytime
1096
1119
  return command_queued(line) if policy == :queue
@@ -1108,12 +1131,37 @@ module Samagotchi
1108
1131
  # Behind a command that waits for the turn's end, a line waits too.
1109
1132
  return wait_for_turn_end(line) if @command_waits
1110
1133
 
1111
- epoch = @engine.input_epoch
1112
1134
  @pending_input_queue.push(line.strip)
1113
- # The user's line cuts a generation that has streamed only thinking
1114
- # for long (steer.cut_after), now or once it has: the model starts the
1115
- # step again with it.
1116
- @engine.cut_for_steer(nil, epoch: epoch)
1135
+ @surface.commit(NEXT_STEP_NOTE)
1136
+ true
1137
+ end
1138
+
1139
+ # /cut TEXT and /queue TEXT during a turn, which the REPL would
1140
+ # otherwise read as prompts (SessionCommands registers them for Tab and
1141
+ # help only). A cut asks the engine to cut the generation only thinking
1142
+ # too long; a queue waits for the turn's end (the REPL's own after-turn
1143
+ # list). Empty text gets the usage line. A line with images never steers
1144
+ # (as a plain one doesn't).
1145
+ # @return [Boolean] whether the turn took it
1146
+ def submit_steer_choice(choice, text)
1147
+ if text.empty?
1148
+ @surface.commit(STEER_CHOICE_USAGE)
1149
+ return true
1150
+ end
1151
+ return image_line_waits(text) if ImageInput.extract(text).any?
1152
+
1153
+ # A /queue waits for the turn's end; a /cut behind a command that
1154
+ # already waits there goes after it too.
1155
+ if choice == :queue || @command_waits
1156
+ @surface.commit(COMMAND_QUEUED)
1157
+ return wait_for_turn_end(text)
1158
+ end
1159
+
1160
+ epoch = @engine.input_epoch
1161
+ @pending_input_queue.push(text)
1162
+ # With no session running the cut is :off; the text still waits for
1163
+ # the running turn's next step.
1164
+ @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
1117
1165
  true
1118
1166
  end
1119
1167
 
@@ -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
@@ -47,6 +47,9 @@ module Samagotchi
47
47
  }
48
48
  title = tool_title(tool_name, call, cwd: cwd)
49
49
  event[:title] = title if title
50
+ # The name the model used when an alias ran a built-in (called_as: "bash"
51
+ # for an execute), for a UI that shows it.
52
+ event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
50
53
  # The model's own words for a command, which the TUI's tool line
51
54
  # shows in place of the cut params.
52
55
  description = command_description(tool_name, call)
@@ -57,6 +57,8 @@ module Samagotchi
57
57
  # The worker runs in its session's working directory.
58
58
  title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
59
59
  started[:title] = title if title
60
+ # The name the model called it by when that was an alias's (bash).
61
+ started[:called_as] = call[:called_as] if call[:called_as]
60
62
  # The full command for a richer UI (params stays cut at 80).
61
63
  view = ToolView.for(call[:name], call)&.to_h
62
64
  started[:view] = view if view
@@ -56,6 +56,13 @@ module Samagotchi
56
56
  "forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
57
57
  }.freeze
58
58
 
59
+ # A name the model uses for a built-in, mapped to that built-in (a model
60
+ # that keeps calling `bash` with execute's arguments; before this such a
61
+ # call died as an unknown tool). It only takes effect when the
62
+ # arguments really are the target's shape; the call then carries
63
+ # called_as: with the model's own spelling.
64
+ ALIASES = { "bash" => "execute" }.freeze
65
+
59
66
  # One built-in's mapping, resolved from its schema and OVERRIDES.
60
67
  Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
61
68
  :options) do
@@ -109,6 +116,14 @@ module Samagotchi
109
116
  def build(name, args, raw: nil)
110
117
  name = name.to_s
111
118
  args = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
119
+ # An alias runs its target (bash → execute); the row is the target's,
120
+ # and the call keeps the model's own spelling on called_as:.
121
+ # Not the target's shape (a String main argument, only its keys) and
122
+ # the alias doesn't take: the call passes through as an unknown tool.
123
+ if (target = ALIASES[name.downcase])
124
+ return aliasable?(row(target), args) ? aliased(name, target, args, raw) : passthrough(name, args, raw)
125
+ end
126
+
112
127
  row = row(name)
113
128
  return passthrough(name, args, raw) unless row
114
129
 
@@ -120,6 +135,24 @@ module Samagotchi
120
135
  call
121
136
  end
122
137
 
138
+ # An execute-shaped call the model spelled +name+: built as its +target+,
139
+ # tagged with the model's spelling for the activity event and the log.
140
+ def aliased(name, target, args, raw)
141
+ call = build(target, args, raw: raw)
142
+ call[:called_as] = name
143
+ call
144
+ end
145
+
146
+ # The alias gate: a String main argument, and every key one the target's
147
+ # schema knows (its aliases included). More, or differently shaped, the
148
+ # call is the model's own and passes through as an unknown tool.
149
+ def aliasable?(row, args)
150
+ return false unless row
151
+
152
+ row.content_keys.map { |key| value(row, args, key) }.any?(String) &&
153
+ (args.keys - (row.content_keys + [row.path_key].compact + row.fields + row.aliases.keys)).empty?
154
+ end
155
+
123
156
  def content_value(row, args)
124
157
  found = row.content_keys.lazy.map { |key| value(row, args, key) }.find { |v| !v.nil? }
125
158
  found.nil? ? "" : found.to_s
@@ -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.50.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")