samagotchi 0.54.0 → 0.56.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +29 -7
  6. data/docs/configuration.md +113 -8
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/context-telemetry.md +23 -5
  9. data/docs/internals/engine.md +54 -18
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/sessions.md +2 -2
  12. data/docs/testing.md +23 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/cache_confirm.rb +51 -0
  17. data/lib/samagotchi/client_id.rb +12 -0
  18. data/lib/samagotchi/config.rb +9 -3
  19. data/lib/samagotchi/engine.rb +102 -8
  20. data/lib/samagotchi/engine_peers.rb +1 -0
  21. data/lib/samagotchi/file_ref.rb +59 -0
  22. data/lib/samagotchi/host_config.rb +6 -3
  23. data/lib/samagotchi/host_registry.rb +4 -3
  24. data/lib/samagotchi/kernel_loop.rb +99 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  26. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  27. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  28. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  29. data/lib/samagotchi/model_settings.rb +5 -2
  30. data/lib/samagotchi/reasoning_history.rb +132 -0
  31. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  32. data/lib/samagotchi/self_report.rb +21 -0
  33. data/lib/samagotchi/session.rb +23 -2
  34. data/lib/samagotchi/session_commands.rb +55 -15
  35. data/lib/samagotchi/session_manager.rb +2 -1
  36. data/lib/samagotchi/session_setup.rb +7 -5
  37. data/lib/samagotchi/setting_explained.rb +20 -0
  38. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  40. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  41. data/lib/samagotchi/terminal_ui.rb +13 -6
  42. data/lib/samagotchi/thinking.rb +20 -25
  43. data/lib/samagotchi/thinking_command.rb +33 -11
  44. data/lib/samagotchi/tool_row_fields.rb +46 -0
  45. data/lib/samagotchi/tool_runner.rb +7 -11
  46. data/lib/samagotchi/tools/builtins.rb +6 -2
  47. data/lib/samagotchi/tools/peers.rb +4 -2
  48. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +48 -4
  51. data/lib/samagotchi/web/capabilities.rb +13 -9
  52. data/lib/samagotchi/web/editor.rb +38 -0
  53. data/lib/samagotchi/web/message_parts.rb +5 -10
  54. data/lib/samagotchi/web/public/activity.js +33 -18
  55. data/lib/samagotchi/web/public/app.js +256 -1133
  56. data/lib/samagotchi/web/public/commands.js +269 -0
  57. data/lib/samagotchi/web/public/composer.js +105 -0
  58. data/lib/samagotchi/web/public/diff_view.js +30 -4
  59. data/lib/samagotchi/web/public/index.html +11 -3
  60. data/lib/samagotchi/web/public/info_bar.js +80 -0
  61. data/lib/samagotchi/web/public/level_chip.js +174 -0
  62. data/lib/samagotchi/web/public/lists_view.js +725 -0
  63. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  64. data/lib/samagotchi/web/public/model_pick.js +1 -1
  65. data/lib/samagotchi/web/public/question_cards.js +2 -1
  66. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  67. data/lib/samagotchi/web/public/refs.js +134 -0
  68. data/lib/samagotchi/web/public/restore.js +81 -0
  69. data/lib/samagotchi/web/public/session_state.js +57 -0
  70. data/lib/samagotchi/web/public/stream_handlers.js +332 -0
  71. data/lib/samagotchi/web/public/thinking_chip.js +38 -144
  72. data/lib/samagotchi/web/public/turn_events.js +17 -258
  73. data/lib/samagotchi/web/public/turn_html.js +5 -1
  74. data/lib/samagotchi/web/server.rb +4 -4
  75. data/lib/samagotchi/worker.rb +17 -5
  76. metadata +18 -2
  77. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "setting_explained"
5
+
6
+ module Samagotchi
7
+ # Which of its own reasoning a chat host's model gets back
8
+ # (LLM::ChatLoop sends it as reasoning_content on its earlier replies):
9
+ #
10
+ # turn the running turn's replies only (the tool-call rounds since the
11
+ # last user message); what Qwen's, Gemma 4's and DeepSeek's
12
+ # templates and the hosted APIs expect
13
+ # drop none
14
+ # all every reply that has reasoning, earlier turns too (a direct
15
+ # DeepSeek API with tools wants this)
16
+ #
17
+ # Order (#resolve): the session's own level (/reasoning-history), then
18
+ # SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
19
+ # hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
20
+ # KernelLoop keeps the running turn's reasoning on its own.
21
+ module ReasoningHistory
22
+ LEVELS = %i[drop turn all].freeze
23
+ DEFAULT = :turn
24
+ KEY = "reasoning_history"
25
+ ENV_NAME = "SAMAGOTCHI_REASONING_HISTORY"
26
+ # The source a session's own level reads as.
27
+ SESSION_SOURCE = "session"
28
+ # The word that unsets a session's own level.
29
+ UNSET = "default"
30
+
31
+ module_function
32
+
33
+ # A session's own level from its file or a command word: :drop, :turn
34
+ # or :all (an explicit turn is the session's own too); nil for anything
35
+ # else, default included. Never warns: a session file keeps a value it
36
+ # can't read as written.
37
+ # @return [Symbol, nil]
38
+ def session_level(value)
39
+ return value if LEVELS.include?(value)
40
+ return nil unless value.is_a?(String)
41
+
42
+ level = value.strip.downcase.to_sym
43
+ LEVELS.include?(level) ? level : nil
44
+ end
45
+
46
+ # A configured value as a level; nil when unset or not a level (that
47
+ # warns once, naming +where+).
48
+ # @return [Symbol, nil]
49
+ def level(value, where)
50
+ return nil if value.nil?
51
+
52
+ text = value.to_s.strip.downcase
53
+ return text.to_sym if LEVELS.include?(text.to_sym)
54
+
55
+ ConfigFile.warn_once "Warning: #{where}: #{KEY} must be one of #{LEVELS.join(", ")}; ignored"
56
+ nil
57
+ end
58
+
59
+ # The level for +target+ and where it came from ("session",
60
+ # "SAMAGOTCHI_REASONING_HISTORY", "models: qwen", "hosts.work"; nil for
61
+ # the default).
62
+ # @param target [HostRegistry::ModelTarget]
63
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
64
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
65
+ # @param session [Symbol, nil] the session's own level (Session#reasoning_history)
66
+ # @return [Array(Symbol, String|nil)]
67
+ def resolve(target, names:, models: nil, session: nil)
68
+ return [session, SESSION_SOURCE] if session
69
+
70
+ env = env_level
71
+ return [env, ENV_NAME] if env
72
+
73
+ models ||= begin
74
+ ConfigFile.model_settings
75
+ rescue StandardError
76
+ {}
77
+ end
78
+ setting = ConfigFile.model_setting(names, :reasoning_history, models: models)
79
+ return [setting.value, "models: #{setting.key}"] if setting
80
+
81
+ host = target.entry&.reasoning_history
82
+ return [host, "hosts.#{target.entry.name}"] if host
83
+
84
+ [DEFAULT, nil]
85
+ end
86
+
87
+ # #resolve, as a SettingExplained with the session's own level beside it.
88
+ # @return [SettingExplained]
89
+ def explain(target, names:, models: nil, session: nil)
90
+ level, source = resolve(target, names: names, models: models, session: session)
91
+ SettingExplained.new(level: level, source: source, own: session)
92
+ end
93
+
94
+ # What changing the level from +from+ to +to+ costs +target+'s prompt
95
+ # cache, at a turn boundary (a change waits for the turn's end):
96
+ # :history to or from all on a chat host, with an earlier model entry
97
+ # that has thinking: every earlier reply from there gains or
98
+ # loses its reasoning_content, so a server that renders it
99
+ # (hosted APIs; llama.cpp's default) reads the conversation
100
+ # again from the first earlier thought;
101
+ # nil drop <-> turn (a turn's first request carries no reasoning
102
+ # under either), no change, nothing thought yet, or a
103
+ # native host (it ignores the level).
104
+ # @param conversation [Array<Hash>] the session's messages
105
+ # @return [Symbol, nil]
106
+ def cache_cost(from, to, target:, conversation:)
107
+ return nil unless target.entry&.chat?
108
+ return nil if from == to || ![from, to].include?(:all)
109
+
110
+ earlier_thought?(conversation) ? :history : nil
111
+ end
112
+
113
+ # Whether +conversation+ has a model entry with thinking (what all
114
+ # sends back as reasoning_content; LLM::ChatLoop#thought?).
115
+ def earlier_thought?(conversation)
116
+ Array(conversation).any? do |entry|
117
+ entry[:role].to_s == "model" && entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
118
+ end
119
+ end
120
+
121
+ # "drop (models: qwen)", "turn (default)".
122
+ def label(level, source) = "#{level} (#{source || "default"})"
123
+
124
+ # SAMAGOTCHI_REASONING_HISTORY as a level, nil when unset or unknown.
125
+ def env_level
126
+ raw, origin = Config.get_with_origin(KEY)
127
+ origin == :env ? level(raw, ENV_NAME) : nil
128
+ rescue StandardError
129
+ nil
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cache_confirm"
4
+ require_relative "reasoning_history"
5
+
6
+ module Samagotchi
7
+ # /reasoning-history, the session's own reasoning_history level
8
+ # (Session#reasoning_history): with no argument, the level the next turn
9
+ # runs under and where it came from; `/reasoning-history drop|turn|all`
10
+ # sets the session's own (an explicit turn too), `/reasoning-history
11
+ # default` unsets it (the session follows SAMAGOTCHI_REASONING_HISTORY,
12
+ # the model, its host, then turn). A change takes effect at the next
13
+ # turn's start; a native host ignores the level (its loop keeps the
14
+ # running turn's reasoning), but the session keeps it for a chat model.
15
+ # A change that rewrites the earlier replies asks first (CacheConfirm);
16
+ # --yes (or -y) applies it.
17
+ class ReasoningHistoryCommand
18
+ NAME = "/reasoning-history"
19
+ USAGE = "usage: #{NAME} [#{(ReasoningHistory::LEVELS + [ReasoningHistory::UNSET]).join("|")}]".freeze
20
+ NATIVE_NOTE = " (ignored on this host: the native loop keeps the running turn's reasoning)"
21
+ # ReasoningHistory.cache_cost's; nil (no cost) says nothing.
22
+ COST_NOTES = {
23
+ history: "Earlier replies gain or lose their reasoning: the server reads the conversation again " \
24
+ "from the first earlier thought."
25
+ }.freeze
26
+
27
+ # @param engine [Engine] #reasoning_history_explained,
28
+ # #reasoning_history_override(=), #session, #chat_model?,
29
+ # #reasoning_history_cache_cost, #history?
30
+ # @param save [#call] saves the session after a change
31
+ def initialize(engine:, save:)
32
+ @engine = engine
33
+ @save = save
34
+ end
35
+
36
+ # @param args [String] what follows /reasoning-history (--yes anywhere)
37
+ # @param confirm [Boolean] ask before a costly change (CacheConfirm);
38
+ # false applies it at once
39
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
40
+ # whether the level changed, and the confirm it asks for (nothing
41
+ # changed then)
42
+ # @raise [ArgumentError] a word that isn't a level (with USAGE)
43
+ def run(args, confirm: true)
44
+ word, yes = CacheConfirm.split(args)
45
+ return [listing(explained), false, nil] if word.empty?
46
+
47
+ level = ReasoningHistory.session_level(word)
48
+ raise ArgumentError, "unknown reasoning history level #{word}\n#{USAGE}" unless level || word == ReasoningHistory::UNSET
49
+
50
+ session = @engine.session or raise ArgumentError, "no session yet"
51
+ before = explained
52
+ ask = confirm && !yes ? ask(before, level, word) : nil
53
+ return ask if ask
54
+
55
+ @engine.reasoning_history_override = level
56
+ @save.call(session)
57
+ after = explained
58
+ [[listing(after), *notes(before, after)].join("\n"), true, nil]
59
+ end
60
+
61
+ # The level, where it came from, and what the session sets itself.
62
+ def listing(explained)
63
+ return "reasoning history: can't be resolved for this model" unless explained
64
+
65
+ lines = ["reasoning history: #{explained.label}"]
66
+ unless explained.own
67
+ lines << " this session's own: none (follows #{ReasoningHistory::ENV_NAME}, the model, its host, then turn)"
68
+ end
69
+ lines << NATIVE_NOTE unless chat?
70
+ lines.join("\n")
71
+ end
72
+
73
+ private
74
+
75
+ # Resolved on any host: a native one ignores it, but /reasoning-history
76
+ # still shows what a chat model would get.
77
+ def explained(own: @engine.reasoning_history_override)
78
+ @engine.reasoning_history_explained(native: true, own: own)
79
+ end
80
+
81
+ # The reply that asks before a costly change, nil when it needn't.
82
+ def ask(before, own, word)
83
+ would = explained(own: own)
84
+ return nil unless before && would
85
+
86
+ cost = @engine.reasoning_history_cache_cost(before.level, would.level)
87
+ return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
88
+
89
+ step = CacheConfirm.confirm(NAME, word)
90
+ [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
91
+ end
92
+
93
+ def chat?
94
+ @engine.chat_model?
95
+ rescue StandardError
96
+ false
97
+ end
98
+
99
+ def notes(before, after)
100
+ return [] unless before && after
101
+ return ["(no change to what the next turn runs under)"] if before.level == after.level
102
+
103
+ cost = @engine.reasoning_history_cache_cost(before.level, after.level)
104
+ ["From the next turn's start (a running turn keeps its own).", COST_NOTES[cost]].compact
105
+ end
106
+ end
107
+ end
@@ -13,6 +13,7 @@ require_relative "model_overlay"
13
13
  require_relative "model_notes"
14
14
  require_relative "prompt_note"
15
15
  require_relative "thinking"
16
+ require_relative "reasoning_history"
16
17
  require_relative "served_model"
17
18
  require_relative "host_registry"
18
19
  require_relative "llm/http"
@@ -67,6 +68,7 @@ module Samagotchi
67
68
  ["loop", model ? loop_for(model, env) : "-"],
68
69
  ["profile", model ? profile_for(model, env) : "-"],
69
70
  ["thinking", model ? thinking_for(model, env) : "-"],
71
+ ["reasoning history", model ? reasoning_history_for(model, env) : "-"],
70
72
  ["served model", model ? served_model_for(model, env) : "-"],
71
73
  ["context window", context_window(env, model)],
72
74
  ["bundles", bundles_summary],
@@ -154,6 +156,8 @@ module Samagotchi
154
156
  PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
155
157
  # The session's own thinking level, beside its model (Builtins.parent_env).
156
158
  SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
159
+ # The session's own reasoning_history level (Builtins.parent_env).
160
+ SESSION_REASONING_HISTORY_ENV = "SAMAGOTCHI_SESSION_REASONING_HISTORY"
157
161
 
158
162
  # The model of the session this chi self runs in (its execute exports
159
163
  # SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
@@ -369,6 +373,23 @@ module Samagotchi
369
373
  "(unknown: #{e.message})"
370
374
  end
371
375
 
376
+ # Which of its reasoning the model gets back and where that came from
377
+ # (ReasoningHistory.resolve); in a session's commands its own level
378
+ # first (SAMAGOTCHI_SESSION_REASONING_HISTORY); a native host keeps the
379
+ # running turn's on its own.
380
+ def reasoning_history_for(model, env)
381
+ registry = HostRegistry.new(env: env)
382
+ target = registry.resolve(model)
383
+ return "native loop: the running turn's, always" unless target.entry&.chat?
384
+
385
+ session = session_model(env) ? ReasoningHistory.session_level(env[SESSION_REASONING_HISTORY_ENV]) : nil
386
+ level, source = ReasoningHistory.resolve(target, names: registry.lookup_names(model, target: target),
387
+ models: ConfigFile.model_settings(env: env), session: session)
388
+ ReasoningHistory.label(level, source)
389
+ rescue StandardError => e
390
+ "(unknown: #{e.message})"
391
+ end
392
+
372
393
  def host_for(model, env)
373
394
  target = HostRegistry.new(env: env).resolve(model)
374
395
  entry = target.entry
@@ -137,6 +137,23 @@ module Samagotchi
137
137
  # read; nil when there is neither.
138
138
  def thinking_file = @thinking&.to_s || @thinking_unread
139
139
 
140
+ # The session's own reasoning_history level (:drop, :turn, :all), before
141
+ # SAMAGOTCHI_REASONING_HISTORY and the model's (ReasoningHistory.resolve);
142
+ # nil when it has none. Saved and carried like #thinking.
143
+ attr_reader :reasoning_history
144
+
145
+ # @param value [Symbol, String, nil] a value that isn't a level reads
146
+ # as nil and is saved back as written, until the session sets its own
147
+ def reasoning_history=(value)
148
+ @reasoning_history = ReasoningHistory.session_level(value)
149
+ unread = @reasoning_history.nil? && value.is_a?(String) && !value.strip.casecmp?(ReasoningHistory::UNSET)
150
+ @reasoning_history_unread = unread ? value : nil
151
+ end
152
+
153
+ # The session file's "reasoning_history": the level, else a value it
154
+ # couldn't read; nil when there is neither.
155
+ def reasoning_history_file = @reasoning_history&.to_s || @reasoning_history_unread
156
+
140
157
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
141
158
  # left), e.g. "loop-guard"; nil when it ended otherwise.
142
159
  def stopped_by
@@ -165,7 +182,7 @@ module Samagotchi
165
182
  used_memory_names: [], project_root: nil,
166
183
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
167
184
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
168
- continues: nil, thinking: nil)
185
+ continues: nil, thinking: nil, reasoning_history: nil)
169
186
  @id = id
170
187
  @metadata_version = metadata_version
171
188
  @mode = mode
@@ -192,6 +209,7 @@ module Samagotchi
192
209
  @continues = continues&.to_s
193
210
  self.llm_context = llm_context
194
211
  self.thinking = thinking
212
+ self.reasoning_history = reasoning_history
195
213
  @archived = false
196
214
  end
197
215
 
@@ -270,6 +288,7 @@ module Samagotchi
270
288
  delegate: delegate,
271
289
  llm_context: setup.llm_context,
272
290
  thinking: setup.thinking,
291
+ reasoning_history: setup.reasoning_history,
273
292
  continues: continues
274
293
  )
275
294
  end
@@ -331,7 +350,8 @@ module Samagotchi
331
350
  "delegate" => false,
332
351
  "llm_context" => nil,
333
352
  "continues" => nil,
334
- "thinking" => nil
353
+ "thinking" => nil,
354
+ "reasoning_history" => nil
335
355
  }.freeze
336
356
 
337
357
  # A session from a parsed session file. +messages+ false leaves the
@@ -453,6 +473,7 @@ module Samagotchi
453
473
  when "delegate" then @delegate
454
474
  when "llm_context" then llm_context_file
455
475
  when "thinking" then thinking_file
476
+ when "reasoning_history" then reasoning_history_file
456
477
  when "prompt_notes" then @prompt_notes.map(&:to_file)
457
478
  when "last_turn" then @last_turn&.to_file
458
479
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
@@ -12,6 +12,7 @@ require_relative "tools/peers"
12
12
  require_relative "commands/registry"
13
13
  require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
+ require_relative "reasoning_history_command"
15
16
 
16
17
  module Samagotchi
17
18
  # The session commands a REPL and a session worker both run: /model,
@@ -31,6 +32,7 @@ module Samagotchi
31
32
  HELP_COMMAND = "/help"
32
33
  LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
33
34
  THINKING_COMMAND = ThinkingCommand::NAME
35
+ REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
34
36
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
35
37
  # shows this many per host; /models <text> lists every match.
36
38
  MODELS_PER_HOST = 20
@@ -44,6 +46,10 @@ module Samagotchi
44
46
  # /help's note per listed mid_turn (the web's command list says the same).
45
47
  MID_TURN_NOTES = { "anytime" => "mid-turn too", "depends" => "show: mid-turn too" }.freeze
46
48
 
49
+ # The commands that ask before a change that restarts the prompt cache
50
+ # (CacheConfirm): #run passes them its confirm flag.
51
+ CONFIRMING = %i[thinking reasoning_history].freeze
52
+
47
53
  # @!attribute status [Symbol] :ok, or :error when the line was refused
48
54
  # @!attribute output [String, nil] what to tell the user
49
55
  # @!attribute changed [Array<Symbol>] :messages and/or :model
@@ -52,7 +58,9 @@ module Samagotchi
52
58
  # @!attribute shell [Boolean] output is a !cmd's own output
53
59
  # @!attribute decision [Symbol, nil] an answer to the continue offer:
54
60
  # :resume, :abort, :abort_with_reason or :invalid
55
- Result = Struct.new(:status, :output, :changed, :model_name, :resume, :shell, :decision, keyword_init: true)
61
+ # @!attribute confirm [CacheConfirm::Confirm, nil] a costly change asks
62
+ # first (nothing changed): the line that applies it
63
+ Result = Struct.new(:status, :output, :changed, :model_name, :resume, :shell, :decision, :confirm, keyword_init: true)
56
64
 
57
65
  # Puts the built-in commands into +registry+, in lookup order (!rollback
58
66
  # before !cmd): the ones #run runs, then the UIs' own (local: Tab and
@@ -92,6 +100,9 @@ module Samagotchi
92
100
  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
101
  registry.register(THINKING_COMMAND, "show or set this session's thinking level (off|low|medium|high|default)",
94
102
  id: :thinking, mid_turn: show_mid_turn(THINKING_COMMAND), match: ->(text) { text.match?(%r{\A/thinking(?:\s+.*)?\z}) }) { |text| thinking(text) }
103
+ registry.register(REASONING_HISTORY_COMMAND, "show or set which of its reasoning this session's model gets back (drop|turn|all|default)",
104
+ id: :reasoning_history, mid_turn: show_mid_turn(REASONING_HISTORY_COMMAND),
105
+ match: ->(text) { text.match?(%r{\A/reasoning-history(?:\s+.*)?\z}) }) { |text| reasoning_history(text) }
95
106
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
96
107
  registry.register("/stats", "show the session's stats", local: true)
97
108
  registry.register("/recap", "show the session's recap", local: true)
@@ -231,13 +242,20 @@ module Samagotchi
231
242
  # How help and errors name an entry (the shell's "!" is "!<cmd>").
232
243
  def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
233
244
 
245
+ # @param confirm [Boolean] a costly /thinking or /reasoning-history
246
+ # change asks first (CacheConfirm): true for a person at a web tab or
247
+ # a TUI; false applies it at once (chi send, a delegate, a plugin,
248
+ # chi -p)
234
249
  # @return [Result, nil] nil when +line+ is not one of these commands
235
- def run(line)
250
+ def run(line, confirm: true)
236
251
  text = line.to_s.strip
237
252
  entry = @registry.lookup(text)
238
253
  return nil unless entry&.handler
239
254
  return run_bundle_command(entry, text) unless entry.source == "core"
240
255
 
256
+ # The two that may ask take the flag; the rest never ask.
257
+ return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
258
+
241
259
  instance_exec(text, &entry.handler)
242
260
  end
243
261
 
@@ -283,8 +301,9 @@ module Samagotchi
283
301
  entry.source == "core" ? 0 : 1
284
302
  end
285
303
 
286
- def reply(output, status: :ok, changed: [])
287
- Result.new(status: status, output: output, changed: changed, model_name: model_name, resume: false, shell: false)
304
+ def reply(output, status: :ok, changed: [], confirm: nil)
305
+ Result.new(status: status, output: output, changed: changed, model_name: model_name, resume: false, shell: false,
306
+ confirm: confirm)
288
307
  end
289
308
 
290
309
  # Explicit escape hatch after a Ctrl-C: discard the salvaged partial
@@ -427,15 +446,27 @@ module Samagotchi
427
446
 
428
447
  # /thinking (ThinkingCommand); :thinking in changed when it set the
429
448
  # session's level.
430
- def thinking(text)
431
- output, changed = ThinkingCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
432
- .run(text.delete_prefix(THINKING_COMMAND))
433
- reply(output, changed: changed ? [:thinking] : [])
449
+ def thinking(text, confirm: true)
450
+ output, changed, ask = ThinkingCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
451
+ .run(text.delete_prefix(THINKING_COMMAND), confirm: confirm)
452
+ reply(output, changed: changed ? [:thinking] : [], confirm: ask)
453
+ rescue ArgumentError => e
454
+ reply(e.message, status: :error)
455
+ end
456
+
457
+ # /reasoning-history (ReasoningHistoryCommand); :reasoning_history in
458
+ # changed when it set the session's level.
459
+ def reasoning_history(text, confirm: true)
460
+ output, changed, ask = ReasoningHistoryCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
461
+ .run(text.delete_prefix(REASONING_HISTORY_COMMAND),
462
+ confirm: confirm)
463
+ reply(output, changed: changed ? [:reasoning_history] : [], confirm: ask)
434
464
  rescue ArgumentError => e
435
465
  reply(e.message, status: :error)
436
466
  end
437
467
 
438
- # A session setting changed (/llm-context, /thinking): saved with its model.
468
+ # A session setting changed (/llm-context, /thinking, /reasoning-history):
469
+ # saved with its model.
439
470
  def save_setup(session)
440
471
  @engine.store_model!(session)
441
472
  @save.call(session)
@@ -453,9 +484,9 @@ module Samagotchi
453
484
  def model_command(input)
454
485
  suffix = input.delete_prefix(MODEL_COMMAND).strip
455
486
  if suffix.empty?
456
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{notes_note}", false] if model_name == @engine.model_ref_for(@default_model)
487
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if model_name == @engine.model_ref_for(@default_model)
457
488
 
458
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{notes_note}", false]
489
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
459
490
  end
460
491
 
461
492
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -516,7 +547,7 @@ module Samagotchi
516
547
 
517
548
  key = alias_name.strip.downcase
518
549
  warn_prefix = previous ? "warning: overwriting alias '#{key}' (#{previous} -> #{model_name}); " : ""
519
- base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}" : "runtime model set to #{model_name}#{model_note}#{thinking_note}"
550
+ base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{reasoning_history_note}" : "runtime model set to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}"
520
551
  ["#{warn_prefix}#{base}; alias '#{key}' -> '#{model_name}' persisted", true]
521
552
  else
522
553
  return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
@@ -525,14 +556,14 @@ module Samagotchi
525
556
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
526
557
 
527
558
  switch_model(@default_model)
528
- return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
559
+ return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
529
560
  end
530
561
 
531
562
  switch_model(arg, persist_default: persist_default)
532
563
  if persist_default
533
- ["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{notes_note}", true]
564
+ ["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
534
565
  else
535
- ["runtime model set to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
566
+ ["runtime model set to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
536
567
  end
537
568
  end
538
569
  end
@@ -592,6 +623,15 @@ module Samagotchi
592
623
  ""
593
624
  end
594
625
 
626
+ # "; reasoning history: drop (models: qwen)" when the chat model has a
627
+ # level set.
628
+ def reasoning_history_note
629
+ summary = @engine.reasoning_history_summary
630
+ summary ? "; reasoning history: #{summary}" : ""
631
+ rescue StandardError
632
+ ""
633
+ end
634
+
595
635
  # " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
596
636
  # which a chat host's model doesn't have (its loop uses none); "" when
597
637
  # there is neither.
@@ -272,7 +272,8 @@ module Samagotchi
272
272
 
273
273
  GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
274
274
  # Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
275
- SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
275
+ SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
276
+ SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
276
277
 
277
278
  # List all sessions, reading status from persisted session.json files.
278
279
  # +project_root+: only that project's sessions (nil: every session).
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "llm_context_override"
4
+ require_relative "reasoning_history"
4
5
  require_relative "thinking"
5
6
 
6
7
  module Samagotchi
@@ -8,15 +9,16 @@ module Samagotchi
8
9
  # value on the paths that spawn a session from a whole setup
9
10
  # (SessionManager.spawn_session, a continue, a plugin's fork, the web's
10
11
  # create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
11
- # +thinking+ a level Symbol (Thinking.session_level) or nil.
12
+ # +thinking+ a level Symbol (Thinking.session_level) or nil,
13
+ # +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil.
12
14
  #
13
15
  # It doesn't save itself: the session keeps each setting in its own field
14
16
  # and file key, and resolves it on its own. A delegate child starts
15
17
  # 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
+ SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history) do
19
+ def initialize(llm_context: nil, thinking: nil, reasoning_history: nil) = super
18
20
 
19
- def empty? = llm_context.nil? && thinking.nil?
21
+ def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil?
20
22
 
21
23
  # A session's own setup, for a session that starts from it.
22
24
  # @param session [Session, nil]
@@ -24,7 +26,7 @@ module Samagotchi
24
26
  def self.of(session)
25
27
  return new if session.nil?
26
28
 
27
- new(llm_context: session.llm_context, thinking: session.thinking)
29
+ new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history)
28
30
  end
29
31
  end
30
32
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A session setting's level and where it came from (Thinking.explain,
5
+ # ReasoningHistory.explain): +source+ is "session",
6
+ # "SAMAGOTCHI_THINKING_LEVEL", "models: qwen", "hosts.work" and the like,
7
+ # nil for the default; +own+ is the session's own level, nil without one.
8
+ # What the commands, /model, /stats, the snapshots and the web read.
9
+ # (LLMContextStrategy::Explained is a different shape: three values,
10
+ # each with its own origin.)
11
+ SettingExplained = Data.define(:level, :source, :own) do
12
+ # "low (session)", "default" with no source.
13
+ def label = source ? "#{level} (#{source})" : level.to_s
14
+
15
+ # The plain form the snapshots, the worker's command_ran event and
16
+ # the web read.
17
+ # @return [Hash] symbol keys
18
+ def summary = { level: level.to_s, source: source, own: own&.to_s }
19
+ end
20
+ end
@@ -54,7 +54,8 @@ module Samagotchi
54
54
  commands = []
55
55
  commands << "/model #{model}" if model && (attach || resume)
56
56
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
57
- commands << "#{ThinkingCommand::NAME} #{thinking}" if (attach || resume) && thinking
57
+ # --yes: typing the flag is the choice (no confirm step).
58
+ commands << "#{ThinkingCommand::NAME} #{thinking} --yes" if (attach || resume) && thinking
58
59
  first_command = commands.size > 1 ? commands : commands.first
59
60
  surface = open_surface
60
61
  attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
@@ -633,6 +633,7 @@ module Samagotchi
633
633
  else
634
634
  @screen.commit("#{paint("model>", 36)} #{output}")
635
635
  end
636
+ prefill_confirm(event)
636
637
  return unless event[:model_name]
637
638
 
638
639
  # After the output, so the status row changes with the line saying why.
@@ -640,6 +641,16 @@ module Samagotchi
640
641
  @status.update(model: event[:model_name], default_model: default_model_name, served: served)
641
642
  end
642
643
 
644
+ # Our change that asks first (CacheConfirm) puts its --yes line in the
645
+ # input: Enter applies it. Another client's question is its own.
646
+ def prefill_confirm(event)
647
+ return unless own?(event[:client_id])
648
+
649
+ confirm = event[:confirm]
650
+ line = confirm.is_a?(Hash) ? confirm[:line] || confirm["line"] : nil
651
+ @reader&.prefill(line.to_s) if line
652
+ end
653
+
643
654
  # Our command the worker couldn't run yet goes back into the prompt, for
644
655
  # Enter once the turn ends (not the launch's --model: that ends the launch).
645
656
  def put_back_busy_command(event)
@@ -598,8 +598,11 @@ module Samagotchi
598
598
  lines << "model notes: #{notes}" unless notes.empty?
599
599
  llm_context = llm_context_stats_text(snapshot[:llm_context])
600
600
  lines << "llm context: #{llm_context}" if llm_context
601
- thinking = thinking_stats_text(snapshot[:thinking])
601
+ thinking = setting_stats_text(snapshot[:thinking])
602
602
  lines << "thinking: #{thinking}" if thinking
603
+ # A chat host's only: a native one ignores it (the summary is nil).
604
+ history = setting_stats_text(snapshot[:reasoning_history])
605
+ lines << "reasoning history: #{history}" if history
603
606
  if snapshot[:served_model]
604
607
  asked = snapshot[:served_model_for]
605
608
  differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
@@ -641,10 +644,10 @@ module Samagotchi
641
644
  "budget #{budget} (#{get.call(:budget_where)})"
642
645
  end
643
646
 
644
- # "low (session)", "default" for /stats (Thinking::Explained#summary;
645
- # symbol or string keys, the attached TUI's come as JSON); nil
646
- # without one.
647
- def thinking_stats_text(summary)
647
+ # "low (session)", "default" for /stats (SettingExplained#summary:
648
+ # thinking, reasoning_history; symbol or string keys, the attached
649
+ # TUI's come as JSON); nil without one.
650
+ def setting_stats_text(summary)
648
651
  return nil unless summary.is_a?(Hash)
649
652
 
650
653
  level = summary[:level] || summary["level"]