samagotchi 0.55.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +6 -2
  4. data/docs/cli.md +29 -7
  5. data/docs/configuration.md +84 -8
  6. data/docs/internals/background-tasks.md +1 -1
  7. data/docs/internals/engine.md +14 -0
  8. data/docs/internals/prompt-caching.md +8 -0
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  12. data/lib/samagotchi/cache_confirm.rb +51 -0
  13. data/lib/samagotchi/client_id.rb +12 -0
  14. data/lib/samagotchi/config.rb +7 -3
  15. data/lib/samagotchi/engine.rb +102 -8
  16. data/lib/samagotchi/engine_peers.rb +1 -0
  17. data/lib/samagotchi/host_config.rb +6 -3
  18. data/lib/samagotchi/host_registry.rb +4 -3
  19. data/lib/samagotchi/kernel_loop.rb +99 -16
  20. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  21. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  22. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  23. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  24. data/lib/samagotchi/model_settings.rb +5 -2
  25. data/lib/samagotchi/reasoning_history.rb +132 -0
  26. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  27. data/lib/samagotchi/self_report.rb +21 -0
  28. data/lib/samagotchi/session.rb +23 -2
  29. data/lib/samagotchi/session_commands.rb +55 -15
  30. data/lib/samagotchi/session_manager.rb +2 -1
  31. data/lib/samagotchi/session_setup.rb +7 -5
  32. data/lib/samagotchi/setting_explained.rb +20 -0
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  35. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  36. data/lib/samagotchi/terminal_ui.rb +13 -6
  37. data/lib/samagotchi/thinking.rb +20 -25
  38. data/lib/samagotchi/thinking_command.rb +33 -11
  39. data/lib/samagotchi/tools/builtins.rb +6 -2
  40. data/lib/samagotchi/tools/peers.rb +4 -2
  41. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +23 -2
  44. data/lib/samagotchi/web/public/app.js +20 -1
  45. data/lib/samagotchi/web/public/commands.js +36 -3
  46. data/lib/samagotchi/web/public/composer.js +5 -1
  47. data/lib/samagotchi/web/public/index.html +6 -3
  48. data/lib/samagotchi/web/public/info_bar.js +5 -3
  49. data/lib/samagotchi/web/public/level_chip.js +174 -0
  50. data/lib/samagotchi/web/public/model_pick.js +1 -1
  51. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  52. data/lib/samagotchi/web/public/session_state.js +5 -0
  53. data/lib/samagotchi/web/public/stream_handlers.js +10 -2
  54. data/lib/samagotchi/web/public/thinking_chip.js +33 -138
  55. data/lib/samagotchi/worker.rb +17 -5
  56. metadata +8 -1
@@ -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"]
@@ -371,12 +371,13 @@ module Samagotchi
371
371
 
372
372
  # `chi -p "/models" --non-interactive`: the command's output; a refused
373
373
  # one fails the run (bin/chi exits 1). The session is saved only when
374
- # the command changed it.
374
+ # the command changed it. Nobody is there to answer a confirm: a
375
+ # costly /thinking or /reasoning-history applies at once.
375
376
  # @return [Symbol, nil] :turn_failed when the command was refused
376
377
  def run_one_shot_command(session)
377
378
  line = @prompt.strip
378
379
  result = begin
379
- @commands.run(line)
380
+ @commands.run(line, confirm: false)
380
381
  rescue StandardError => e
381
382
  SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
382
383
  end
@@ -789,11 +790,14 @@ module Samagotchi
789
790
 
790
791
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
791
792
  # an anytime command's cards print as it shows them (btw's
792
- # "thinking…" before the answer).
793
+ # "thinking…" before the answer). Piped input has nobody to answer a
794
+ # confirm (the next line is already written): a costly /thinking or
795
+ # /reasoning-history applies at once.
796
+ confirm = !piped_input?
793
797
  command = if command_registry.anytime?(input)
794
- @engine.running_anytime { @commands.run(input) }
798
+ @engine.running_anytime { @commands.run(input, confirm: confirm) }
795
799
  else
796
- @commands.run(input)
800
+ @commands.run(input, confirm: confirm)
797
801
  end
798
802
  if command
799
803
  show_command_result(command)
@@ -874,7 +878,9 @@ module Samagotchi
874
878
  session.save unless Session.exist?(session.id)
875
879
  end
876
880
 
877
- # A !cmd shows its own output; everything else is the model> line.
881
+ # A !cmd shows its own output; everything else is the model> line. A
882
+ # change that asks first (CacheConfirm) puts its --yes line in the next
883
+ # input: Enter applies it.
878
884
  def show_command_result(result)
879
885
  if result.shell
880
886
  @surface.commit(result.output)
@@ -882,6 +888,7 @@ module Samagotchi
882
888
  elsif !result.output.nil?
883
889
  @surface.commit("\nmodel> #{result.output}")
884
890
  end
891
+ queue_input_prefill(result.confirm.line) if result.confirm && !piped_input?
885
892
  sync_model_mirrors if result.changed.include?(:model)
886
893
  end
887
894
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "model_profile"
5
+ require_relative "setting_explained"
4
6
 
5
7
  module Samagotchi
6
8
  # How much a model thinks: one level per model, host or run, mapped to each
@@ -38,21 +40,6 @@ module Samagotchi
38
40
  # The source a session's own level reads as.
39
41
  SESSION_SOURCE = "session"
40
42
 
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
-
56
43
  module_function
57
44
 
58
45
  # A session's own level from its file or a command word: :off, :low,
@@ -113,11 +100,11 @@ module Samagotchi
113
100
  [DEFAULT, nil]
114
101
  end
115
102
 
116
- # #resolve, as an Explained with the session's own level beside it.
117
- # @return [Explained]
103
+ # #resolve, as a SettingExplained with the session's own level beside it.
104
+ # @return [SettingExplained]
118
105
  def explain(target, names:, models: nil, session: nil)
119
106
  level, source = resolve(target, names: names, models: models, session: session)
120
- Explained.new(level: level, source: source, own: session)
107
+ SettingExplained.new(level: level, source: source, own: session)
121
108
  end
122
109
 
123
110
  # thinking.level as a level and its origin (:cli, :env, :file), or nil.
@@ -133,17 +120,25 @@ module Samagotchi
133
120
  end
134
121
 
135
122
  # 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.
123
+ # note; CacheConfirm asks before the costly ones):
124
+ # :full Gemma 4's prompt starts with <|think|> (the native
125
+ # profile, or a local chat server whose model name says
126
+ # gemma: its template writes the same token), so the whole
127
+ # prompt is read again;
128
+ # :provider a remote chat host gets other request fields: Claude's
129
+ # message breakpoints and OpenAI's prefix cache are lost;
130
+ # :tail native Qwen, a local chat server's Qwen and the rest:
131
+ # only the tail changes (llama.cpp and Splash keep the rest).
142
132
  # @param target [HostRegistry::ModelTarget]
143
133
  # @param profile [String, nil] the native prompt profile's name
144
134
  # @return [Symbol]
145
135
  def cache_cost(target, profile: nil)
146
- return :provider if target.entry&.chat?
136
+ entry = target.entry
137
+ if entry&.chat?
138
+ return :provider if entry.remote?
139
+
140
+ return ModelProfile.inferred_profile_name(target.bare_model) == "gemma4" ? :full : :tail
141
+ end
147
142
 
148
143
  profile == "gemma4" ? :full : :tail
149
144
  end