samagotchi 0.55.0 → 0.57.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +30 -8
  6. data/docs/configuration.md +84 -8
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/engine.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +63 -0
  13. data/docs/sessions.md +1 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  16. data/lib/samagotchi/bootstrap_command.rb +2 -0
  17. data/lib/samagotchi/broadcast_command.rb +2 -0
  18. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  19. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/cache_confirm.rb +51 -0
  27. data/lib/samagotchi/client_id.rb +12 -0
  28. data/lib/samagotchi/config.rb +7 -3
  29. data/lib/samagotchi/context_command.rb +2 -0
  30. data/lib/samagotchi/desktop/macos.rb +3 -0
  31. data/lib/samagotchi/engine.rb +102 -8
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/gem_update.rb +3 -0
  34. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  35. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  36. data/lib/samagotchi/host_config.rb +6 -3
  37. data/lib/samagotchi/host_registry.rb +19 -3
  38. data/lib/samagotchi/installed_versions.rb +3 -0
  39. data/lib/samagotchi/kernel_loop.rb +99 -16
  40. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  41. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  42. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  43. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  44. data/lib/samagotchi/model_settings.rb +5 -2
  45. data/lib/samagotchi/plugin/context.rb +5 -0
  46. data/lib/samagotchi/reasoning_history.rb +132 -0
  47. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  48. data/lib/samagotchi/redacted_inspect.rb +28 -0
  49. data/lib/samagotchi/self_report.rb +21 -0
  50. data/lib/samagotchi/session.rb +23 -2
  51. data/lib/samagotchi/session_commands.rb +55 -15
  52. data/lib/samagotchi/session_manager.rb +2 -1
  53. data/lib/samagotchi/session_setup.rb +7 -5
  54. data/lib/samagotchi/setting_explained.rb +20 -0
  55. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  56. data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
  57. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  58. data/lib/samagotchi/terminal_ui.rb +13 -6
  59. data/lib/samagotchi/thinking.rb +20 -25
  60. data/lib/samagotchi/thinking_command.rb +33 -11
  61. data/lib/samagotchi/tool_runner.rb +14 -3
  62. data/lib/samagotchi/tools/builtins.rb +6 -2
  63. data/lib/samagotchi/tools/execute.rb +3 -2
  64. data/lib/samagotchi/tools/peers.rb +4 -2
  65. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +29 -6
  68. data/lib/samagotchi/web/public/app.js +44 -9
  69. data/lib/samagotchi/web/public/commands.js +36 -3
  70. data/lib/samagotchi/web/public/composer.js +5 -1
  71. data/lib/samagotchi/web/public/index.html +22 -5
  72. data/lib/samagotchi/web/public/info_bar.js +5 -3
  73. data/lib/samagotchi/web/public/level_chip.js +174 -0
  74. data/lib/samagotchi/web/public/model_pick.js +1 -1
  75. data/lib/samagotchi/web/public/question_card.js +5 -3
  76. data/lib/samagotchi/web/public/question_cards.js +21 -1
  77. data/lib/samagotchi/web/public/read_view.js +74 -0
  78. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  79. data/lib/samagotchi/web/public/ref_scan.js +206 -0
  80. data/lib/samagotchi/web/public/refs.js +19 -10
  81. data/lib/samagotchi/web/public/session_state.js +5 -0
  82. data/lib/samagotchi/web/public/stream_handlers.js +10 -2
  83. data/lib/samagotchi/web/public/thinking_chip.js +33 -138
  84. data/lib/samagotchi/web/public/turn_html.js +4 -2
  85. data/lib/samagotchi/web/public/turn_view.js +5 -3
  86. data/lib/samagotchi/web/token.rb +3 -0
  87. data/lib/samagotchi/worker.rb +17 -5
  88. metadata +13 -1
@@ -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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cache_confirm"
3
4
  require_relative "thinking"
4
5
 
5
6
  module Samagotchi
@@ -10,6 +11,7 @@ module Samagotchi
10
11
  # model, its host, then thinking.level). A change takes effect at the
11
12
  # next turn's start (the REPL and the worker run it between turns), and
12
13
  # the reply says what it costs the prompt cache (Thinking.cache_cost).
14
+ # A costly change asks first (CacheConfirm); --yes (or -y) applies it.
13
15
  class ThinkingCommand
14
16
  NAME = "/thinking"
15
17
  SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
@@ -17,35 +19,43 @@ module Samagotchi
17
19
 
18
20
  COST_NOTES = {
19
21
  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
+ provider: "The request's thinking fields change: a hosted API (OpenRouter, Claude, OpenAI) reads the whole " \
23
+ "conversation again.",
22
24
  tail: "Only the prompt's tail changes: the cache keeps the rest."
23
25
  }.freeze
24
26
 
25
- # @param engine [Engine] #thinking_explained, #thinking_override(=),
26
- # #session, #thinking_cache_cost
27
+ # @param engine [Engine] #thinking_explained(own:), #thinking_override(=),
28
+ # #session, #thinking_cache_cost, #history?
27
29
  # @param save [#call] saves the session after a change
28
30
  def initialize(engine:, save:)
29
31
  @engine = engine
30
32
  @save = save
31
33
  end
32
34
 
33
- # @param args [String] what follows /thinking
34
- # @return [Array(String, Boolean)] the reply and whether the level changed
35
+ # @param args [String] what follows /thinking (--yes anywhere)
36
+ # @param confirm [Boolean] ask before a costly change (CacheConfirm);
37
+ # false applies it at once (chi send, a delegate, a start flag)
38
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
39
+ # whether the level changed, and the confirm it asks for (nothing
40
+ # changed then)
35
41
  # @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?
42
+ def run(args, confirm: true)
43
+ word, yes = CacheConfirm.split(args)
44
+ return [listing(@engine.thinking_explained), false, nil] if word.empty?
39
45
 
40
46
  level = word.to_sym
41
47
  raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
42
48
 
43
49
  session = @engine.session or raise ArgumentError, "no session yet"
50
+ own = SETTABLE.include?(level) ? level : nil
44
51
  before = @engine.thinking_explained
45
- @engine.thinking_override = SETTABLE.include?(level) ? level : nil
52
+ ask = confirm && !yes ? ask(before, own, word) : nil
53
+ return ask if ask
54
+
55
+ @engine.thinking_override = own
46
56
  @save.call(session)
47
57
  after = @engine.thinking_explained
48
- [[listing(after), *notes(before, after)].join("\n"), true]
58
+ [[listing(after), *notes(before, after)].join("\n"), true, nil]
49
59
  end
50
60
 
51
61
  # The level, where it came from, and what the session sets itself.
@@ -62,6 +72,18 @@ module Samagotchi
62
72
 
63
73
  private
64
74
 
75
+ # The reply that asks before a costly change, nil when it needn't.
76
+ def ask(before, own, word)
77
+ would = @engine.thinking_explained(own: own)
78
+ return nil unless before && would
79
+
80
+ cost = @engine.thinking_cache_cost
81
+ return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
82
+
83
+ step = CacheConfirm.confirm(NAME, word)
84
+ [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
85
+ end
86
+
65
87
  def notes(before, after)
66
88
  return [] unless before && after
67
89
  return ["(no change to what the next turn runs under)"] if before.level == after.level
@@ -28,7 +28,8 @@ module Samagotchi
28
28
 
29
29
  # @param kernel [KernelLoop] read lazily: Engine sets its hooks after
30
30
  # the kernel is built.
31
- # @param clock [#call] monotonic seconds, for the approval wait
31
+ # @param clock [#call] monotonic seconds, for the approval wait and the
32
+ # dispatch time (after_tool_call's elapsed_ms)
32
33
  def initialize(kernel, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
33
34
  @kernel = kernel
34
35
  @clock = clock
@@ -71,7 +72,14 @@ module Samagotchi
71
72
  end
72
73
  before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
73
74
  index_before = before.equal?(NOT_AN_EDIT) ? nil : memory_index_size(call)
74
- result = verdict.deny? ? denied(call, verdict) : dispatch(call)
75
+ elapsed_ms = nil
76
+ if verdict.deny?
77
+ result = denied(call, verdict)
78
+ else
79
+ dispatched_at = @clock.call
80
+ result = dispatch(call)
81
+ elapsed_ms = ((@clock.call - dispatched_at) * 1000).round
82
+ end
75
83
  diff = file_change(call, before)
76
84
  if diff && refresh_memory_index(call)
77
85
  result = with_index_note(result, index_before, call)
@@ -80,6 +88,9 @@ module Samagotchi
80
88
  result, images = attach_images(call, result) if result[:images]
81
89
 
82
90
  output = scrub(result[:output].to_s)
91
+ # The tool's own text, for after_tool_call: no "[tool]" lead, no
92
+ # ran-as line, no cap.
93
+ tool_text = output.sub(/\A\[#{Regexp.escape(call[:name].to_s)}\](?:\n| )/, "")
83
94
  # The gate may have replaced the call (known-names corrects a near
84
95
  # miss): one leading line tells the model what actually ran.
85
96
  if (changed = changed_call_line(original, call))
@@ -104,7 +115,7 @@ module Samagotchi
104
115
  # After the completed event: a hook's card (check-in, skills) prints
105
116
  # under the call's tool row, not before it.
106
117
  fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
107
- status: status || "error" })
118
+ status: status || "error", call: call.dup, elapsed_ms: elapsed_ms, result: tool_text })
108
119
 
109
120
  run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
110
121
  run[:images] = images if images&.any?
@@ -121,7 +121,8 @@ module Samagotchi
121
121
  # /model), which chi self reports; unset when unknown, so a value
122
122
  # inherited from an outer session never passes for this one. So too
123
123
  # the session's own thinking level (SAMAGOTCHI_SESSION_THINKING, unset
124
- # without one), which chi self puts first. A
124
+ # without one), which chi self puts first, and its own reasoning_history
125
+ # level (SAMAGOTCHI_SESSION_REASONING_HISTORY, the same way). A
125
126
  # worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
126
127
  # is unset: it was that run's, not the default of a chi run here.
127
128
  def parent_env(kctx, env: ENV)
@@ -129,9 +130,11 @@ module Samagotchi
129
130
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
130
131
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
131
132
  thinking = peers.respond_to?(:thinking) ? peers.thinking&.to_s : nil
133
+ history = peers.respond_to?(:reasoning_history) ? peers.reasoning_history&.to_s : nil
132
134
  { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
133
135
  SESSION_MODEL_ENV => model.empty? ? nil : model,
134
- SESSION_THINKING_ENV => thinking }.merge(cli_model_unset(env))
136
+ SESSION_THINKING_ENV => thinking,
137
+ SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env))
135
138
  end
136
139
 
137
140
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -144,6 +147,7 @@ module Samagotchi
144
147
 
145
148
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
146
149
  SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
150
+ SESSION_REASONING_HISTORY_ENV = "SAMAGOTCHI_SESSION_REASONING_HISTORY"
147
151
 
148
152
  # task_create's parent_env, less the unsets of a variable the model
149
153
  # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
@@ -24,6 +24,8 @@ module Samagotchi
24
24
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
25
25
  # The first words of a command the user's Stop killed.
26
26
  STOPPED_BY_USER = "Error: command stopped by the user"
27
+ # The first words of a command killed at the limit (hooks match on it).
28
+ TIMEOUT_PREFIX = "Error: command timed out after"
27
29
  TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
28
30
  # How long the output may stay open once the shell has exited: a
29
31
  # `server &` child keeps the pipes, and the read would never end.
@@ -68,7 +70,7 @@ module Samagotchi
68
70
  rescue CommandTimedOut => e
69
71
  # Keep the "Error:" first line (callers classify on it), say how to run
70
72
  # a long command, and return whatever it printed before it was killed.
71
- stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
73
+ stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
72
74
  rescue CommandCancelled => e
73
75
  stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
74
76
  rescue StandardError => e
@@ -189,7 +191,6 @@ module Samagotchi
189
191
  def self.timeout_seconds
190
192
  OutputGuardrails.config_positive_int("execute.timeout_sec", TIMEOUT_SEC)
191
193
  end
192
- private_class_method :timeout_seconds
193
194
 
194
195
  # Resolves the working directory for the child process. An empty/absent
195
196
  # cwd defaults to the project root (Dir.pwd), mirroring task_runtime.
@@ -9,10 +9,12 @@ module Samagotchi
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
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
+ # (thinking: nil without one) and reasoning_history level
13
+ # (reasoning_history: nil without one). Engine hands KernelLoop one that follows its current
13
14
  # session (Engine::PeerView); this plain one is for specs and a kernel
14
15
  # without an Engine.
15
- Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, keyword_init: true) do
16
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
17
+ keyword_init: true) do
16
18
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
17
19
  def cancelled?
18
20
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -22,7 +22,8 @@ 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 SAMAGOTCHI_SESSION_THINKING].freeze
25
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
26
+ SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
26
27
  # task_wait and task_get add it to a task the user stopped (the web's
27
28
  # stop-task button), so the model doesn't rerun the job.
28
29
  # 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.55.0"
4
+ VERSION = "0.57.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -26,6 +26,7 @@ require_relative "../llm_context_override"
26
26
  require_relative "../model_catalog"
27
27
  require_relative "../model_profile"
28
28
  require_relative "../thinking"
29
+ require_relative "../reasoning_history"
29
30
  require_relative "../sampling_settings"
30
31
  require_relative "../served_model"
31
32
  require_relative "../project_scope"
@@ -517,7 +518,7 @@ module Samagotchi
517
518
  end
518
519
 
519
520
  # The thinking level +model+ starts at, with where it came from (the
520
- # start page's think chip; Thinking::Explained#summary, no session).
521
+ # start page's think chip; SettingExplained#summary, no session).
521
522
  def add_thinking(model, target, names, settings)
522
523
  model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
523
524
  rescue StandardError
@@ -561,7 +562,7 @@ module Samagotchi
561
562
  end
562
563
 
563
564
  # A session's thinking level with where it came from and its own
564
- # (Thinking::Explained#summary), as its worker would resolve it; nil
565
+ # (SettingExplained#summary), as its worker would resolve it; nil
565
566
  # when it can't be. Config and the registry only: no network.
566
567
  def thinking_for(session)
567
568
  registry = host_registry
@@ -573,6 +574,22 @@ module Samagotchi
573
574
  nil
574
575
  end
575
576
 
577
+ # A session's reasoning_history level with where it came from and its
578
+ # own (SettingExplained#summary), as its worker would resolve it; nil
579
+ # on a native host (it ignores the level) or when it can't be. Config
580
+ # and the registry only: no network.
581
+ def reasoning_history_for(session)
582
+ registry = host_registry
583
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
584
+ target = registry.resolve(name)
585
+ return nil unless target.entry&.chat?
586
+
587
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
588
+ ReasoningHistory.explain(target, names: names, session: session.reasoning_history).summary
589
+ rescue StandardError
590
+ nil
591
+ end
592
+
576
593
  # Built again when config.yml's hosts: changed (compared as read: the
577
594
  # YAML read is mtime-cached), so the model picker lists a host added
578
595
  # while chi web runs. An injected registry (specs) stays.
@@ -689,10 +706,12 @@ module Samagotchi
689
706
 
690
707
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
691
708
  # link of that session's chain (SessionManager.continue_session), in its
692
- # folder, on its model and with its llm_context and thinking level, so
693
- # dir, model, llm_context and thinking are refused beside it. 409 continued (with next_id, the
694
- # link that continues it already: the page opens that one),
695
- # open_children (ids), folder_gone, busy, scratch or owned_by_tui.
709
+ # folder, on its model and with its own setup (SessionSetup: llm_context,
710
+ # thinking and reasoning_history levels), so dir, model, llm_context and
711
+ # thinking are refused beside it (a create takes no reasoning_history).
712
+ # 409 continued (with next_id, the link that continues it already: the
713
+ # page opens that one), open_children (ids), folder_gone, busy, scratch
714
+ # or owned_by_tui.
696
715
  def handle_continue(body, prompt, idle)
697
716
  given = body["continues"]
698
717
  unless given.is_a?(String) && !given.strip.empty?
@@ -820,6 +839,10 @@ module Samagotchi
820
839
  session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
821
840
  # The thinking level the next turn runs at (the think chip), the same way.
822
841
  session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
842
+ # Its reasoning history (the reasoning chip): the worker's (nil on a
843
+ # native host), else the server's reading (an older worker, none).
844
+ history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
845
+ session_json = session_json.merge(reasoning_history: history)
823
846
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
824
847
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
825
848
  json_response(200, {
@@ -30,6 +30,7 @@ import {
30
30
  chipModel as thinkingChipModel, createThinkingControl,
31
31
  withChoice as withThinkingChoice,
32
32
  } from "./thinking_chip.js";
33
+ import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
33
34
  import { renderInfoBar } from "./info_bar.js";
34
35
  import { createShownTexts } from "./shown_texts.js";
35
36
  import { MODEL_KEY } from "./model_pick.js";
@@ -65,9 +66,10 @@ import {
65
66
  } from "./timing.js";
66
67
  import { createLiveTurn } from "./live_turn.js";
67
68
  import { installRefClicks, setRefKinds } from "./refs.js";
69
+ import { linkRefs, touchedFiles } from "./ref_scan.js";
68
70
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
69
71
  import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
70
- import { drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
72
+ import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
71
73
  import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
72
74
  import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
73
75
  import { createStreamHandlers } from "./stream_handlers.js";
@@ -134,6 +136,15 @@ const thinkingControl = createThinkingControl({
134
136
  running: () => liveTurn.running,
135
137
  run: (line) => runSessionCommand(line),
136
138
  });
139
+ // The open session's reasoning history chip, after the thinking one (a
140
+ // chat host's only): /reasoning-history runs in the session's worker.
141
+ const reasoningHistoryControl = createReasoningHistoryControl({
142
+ el: infoTextEl,
143
+ summary: () => (selected ? current.reasoningHistory : null),
144
+ session: () => selected,
145
+ running: () => liveTurn.running,
146
+ run: (line) => runSessionCommand(line),
147
+ });
137
148
  // The start page's thinking chip: the picked model's level, and a level
138
149
  // for the next new chat only (sent with its create, then dropped).
139
150
  const thinkingNewEl = $("#thinkingNew");
@@ -763,6 +774,7 @@ function renderSessionView(data, { kept = [] } = {}) {
763
774
  const activeTurnId = current.timing.activeTurn?.id || null;
764
775
  llmContextControl.refresh();
765
776
  thinkingControl.refresh();
777
+ reasoningHistoryControl.refresh();
766
778
  if (data.messages && data.messages.length) renderHistory(data.messages);
767
779
  else renderHistory(data.history || []);
768
780
  restoreCommandBubbles(historyEl, kept);
@@ -826,6 +838,7 @@ function renderHistory(items) {
826
838
  }
827
839
  if (typeof items[0] === "object" && items[0].role) {
828
840
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
841
+ linkTextRefs();
829
842
  } else {
830
843
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
831
844
  }
@@ -890,20 +903,34 @@ async function readTurnEnd() {
890
903
  } catch(_){}
891
904
  }
892
905
 
906
+ // The turn's answer as its rendered markdown, once it is final (the
907
+ // turn-end read, an answer_display re-read); then its refs.
893
908
  function applyFinalMarkdown(messages) {
894
909
  const message = [...messages].reverse().find(
895
910
  (item) => item?.role === "assistant" && typeof item.html === "string",
896
911
  );
897
- if (!message) return;
898
-
899
- const bubble = findInTurnRoots(".bubble.output")
912
+ const bubble = message && findInTurnRoots(".bubble.output")
900
913
  .reverse()
901
914
  .find((item) => showsAnswer(item, message, normalize));
902
- if (!bubble) return;
903
-
904
- bubble.classList.add("markdown");
905
- bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
906
- bubble.innerHTML = message.html;
915
+ if (bubble) {
916
+ bubble.classList.add("markdown");
917
+ bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
918
+ bubble.innerHTML = message.html;
919
+ }
920
+ linkTextRefs();
921
+ }
922
+
923
+ // The `path:line` refs in the answers' and closed steps' texts that name a
924
+ // file the session's rows (and cards) touched, on this page (ref_scan.js).
925
+ // Not mid-stream: at a turn's end and after a reload's render. A ref
926
+ // already made stays as it is.
927
+ function linkTextRefs() {
928
+ const roots = turnRoots();
929
+ const touched = touchedFiles(roots);
930
+ if (!touched.size) return;
931
+ for (const root of roots) {
932
+ for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
933
+ }
907
934
  }
908
935
 
909
936
  function seedSeenFromDom() {
@@ -1429,6 +1456,7 @@ function updateInfoBar() {
1429
1456
  kids: childrenSummary({ id: selected }, allSessions),
1430
1457
  llmContext: current.llmContext,
1431
1458
  thinking: current.thinking,
1459
+ reasoningHistory: current.reasoningHistory,
1432
1460
  // The model notes the session's prompt carried.
1433
1461
  notes: promptNotesChip(current.promptNotes),
1434
1462
  memories: current.usedMemories,
@@ -1596,6 +1624,7 @@ const streamHandlers = createStreamHandlers({
1596
1624
  contextChips,
1597
1625
  llmContextControl,
1598
1626
  thinkingControl,
1627
+ reasoningHistoryControl,
1599
1628
  sentIds,
1600
1629
  earlyRestores,
1601
1630
  stream: {
@@ -2284,6 +2313,12 @@ const annotateBar = createAnnotateBar({
2284
2313
  },
2285
2314
  });
2286
2315
  historyEl.addEventListener("scroll", annotateBar.schedule);
2316
+ // A command bubble's Apply (CacheConfirm): sends the change's --yes line
2317
+ // once; its own bubble then says what it did.
2318
+ historyEl.addEventListener("click", (e) => {
2319
+ const button = e.target.closest?.("[data-confirm-line]");
2320
+ if (button) applyConfirm(button, runSessionCommand);
2321
+ });
2287
2322
  turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
2288
2323
  turnRoots().forEach((root) => installCopy(root));
2289
2324
 
@@ -148,13 +148,17 @@ export function continueLine(choice, reason = "") {
148
148
  // it shows; its command_ran fills that bubble in (`commandId`). So is a
149
149
  // command queued for after the running turn (`queued`): its command_queued
150
150
  // (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
151
- // dropped (styled as failed).
151
+ // dropped (styled as failed). A change that restarts the prompt cache asks
152
+ // first (CacheConfirm): `confirm` is the line that applies it, and this
153
+ // tab's own bubble (`own`) gets an Apply button that sends it.
152
154
  export const QUEUED_TEXT = "queued: runs after this turn";
155
+ export const APPLY_TEXT = "Apply (restarts the cache)";
153
156
 
154
157
  export function commandView(event, myId) {
155
158
  const own = isOwn(event.client_id, myId);
156
159
  const waiting = event.waits === "turn_end";
157
160
  return {
161
+ own,
158
162
  label: own ? null : clientLabel(event.client_id),
159
163
  line: event.line || "",
160
164
  text: event.output || (waiting ? QUEUED_TEXT : ""),
@@ -166,6 +170,7 @@ export function commandView(event, myId) {
166
170
  queued: waiting || event.queued === true,
167
171
  waiting,
168
172
  commandId: event.command_id || null,
173
+ confirm: typeof event.confirm?.line === "string" ? event.confirm.line : null,
169
174
  // A card's action (Nudge…): no bubble unless it says something back.
170
175
  hidden: event.card === true && !event.output && event.status !== "error",
171
176
  };
@@ -183,8 +188,12 @@ export function historyHasCommandBubble(historyBubbles, line) {
183
188
  }
184
189
 
185
190
  // A command bubble's line and output (again, when its command_ran fills in
186
- // an anytime command's bubble in place).
187
- export function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
191
+ // an anytime command's bubble in place), and this tab's own change's Apply
192
+ // button when it asks first (a delegated click sends its line: app.js).
193
+ // Command bubbles are page-only: after a reload the button is gone, and
194
+ // the change is typed again.
195
+ export function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null,
196
+ own = false, confirm = null }) {
188
197
  div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
189
198
  if (commandId) div.dataset.commandId = commandId;
190
199
  div.replaceChildren();
@@ -198,6 +207,30 @@ export function drawCommandBubble(div, { label, line, text, busy = false, failed
198
207
  body.textContent = text;
199
208
  div.appendChild(body);
200
209
  }
210
+ if (own && confirm) {
211
+ const apply = document.createElement("button");
212
+ apply.type = "button";
213
+ apply.className = "command-confirm";
214
+ apply.dataset.confirmLine = confirm;
215
+ apply.title = confirm;
216
+ apply.textContent = APPLY_TEXT;
217
+ div.appendChild(apply);
218
+ }
219
+ }
220
+
221
+ // A click on a bubble's Apply button: sends its --yes line once (the
222
+ // button is disabled at once), and enables it again when the line wasn't
223
+ // sent (+run+ resolves false or throws), so the user can try again.
224
+ export async function applyConfirm(button, run) {
225
+ if (!button || button.disabled) return;
226
+ button.disabled = true;
227
+ let sent = false;
228
+ try {
229
+ sent = await run(button.dataset.confirmLine);
230
+ } catch {
231
+ sent = false;
232
+ }
233
+ if (!sent) button.disabled = false;
201
234
  }
202
235
 
203
236
  // The prompt and command bubbles in the history, in order, by kind
@@ -86,14 +86,18 @@ export function createComposerCommands(deps) {
86
86
 
87
87
  // A session command from a control (the llm ctx chip), not the composer:
88
88
  // its command_ran shows its line and reply, as a typed one's would.
89
+ // Resolves true once the worker took it, false when it wasn't sent (no
90
+ // session, a refusal or a network error: the failed bubble says why).
89
91
  async function run(line) {
90
92
  const id = session();
91
- if (!id) return;
93
+ if (!id) return false;
92
94
  try {
93
95
  await sendCommand(id, line, { clientId });
94
96
  followWokenWorker();
97
+ return true;
95
98
  } catch (e) {
96
99
  failed(line, e.message);
100
+ return false;
97
101
  }
98
102
  }
99
103