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
@@ -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
@@ -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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_activity"
4
+ require_relative "tool_view"
5
+ require_relative "file_ref"
6
+
7
+ module Samagotchi
8
+ # The fields a tool row shows beyond its params, built from the call in
9
+ # one place for every path: the live tool_call_started (ToolRunner#run)
10
+ # and a reload from the saved call (Web::MessageParts#tool_part). The
11
+ # bridge's snapshot and replay (Bridge::TurnAccumulator) copy them by
12
+ # these lists, so a new field is added here and travels everywhere.
13
+ #
14
+ # They travel in two places:
15
+ # * ACTIVITY_KEYS: top-level on tool_call_started, inside +activity+ on
16
+ # tool_call_completed (ToolActivity.tool_activity_event builds that
17
+ # activity itself, for the TUI and the metrics);
18
+ # * EVENT_KEYS: top-level on both, so a UI that missed the start (a
19
+ # replay gap) builds its row from the completed event.
20
+ #
21
+ # params and label stay where they are: their sources differ per path
22
+ # (the registry's preview, a plugin's label, the saved shown params).
23
+ module ToolRowFields
24
+ ACTIVITY_KEYS = %i[title called_as].freeze
25
+ EVENT_KEYS = %i[view ref].freeze
26
+ KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
27
+
28
+ module_function
29
+
30
+ # @param cwd [String, nil] the session's working directory (the title is
31
+ # relative to it when inside it)
32
+ # @return [Hash] title (a few words: a project-relative path, a command
33
+ # without its "cd … &&"), called_as (the name the model called it by
34
+ # when that was an alias's: bash), view (ToolView: the full command)
35
+ # and ref (FileRef: the file a file tool touched), each only when
36
+ # there is one
37
+ def for(tool_name, call, cwd:)
38
+ {
39
+ title: ToolActivity.tool_title(tool_name, call, cwd: cwd),
40
+ called_as: call[:called_as],
41
+ view: ToolView.for(tool_name, call)&.to_h,
42
+ ref: FileRef.for(tool_name, call, cwd: cwd)&.to_h
43
+ }.compact
44
+ end
45
+ end
46
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tool_activity"
4
- require_relative "tool_view"
4
+ require_relative "tool_row_fields"
5
5
  require_relative "guardrails"
6
6
  require_relative "vision_context"
7
7
  require_relative "log"
@@ -54,14 +54,10 @@ module Samagotchi
54
54
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
55
55
  tool: call[:name], call: call.dup, params: params }
56
56
  started[:label] = label if label
57
- # The worker runs in its session's working directory.
58
- title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
59
- started[:title] = title if title
60
- # The name the model called it by when that was an alias's (bash).
61
- started[:called_as] = call[:called_as] if call[:called_as]
62
- # The full command for a richer UI (params stays cut at 80).
63
- view = ToolView.for(call[:name], call)&.to_h
64
- started[:view] = view if view
57
+ # Its row fields (ToolRowFields: title, called_as, view, ref); the worker runs in
58
+ # its session's working directory.
59
+ fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
60
+ started.merge!(fields)
65
61
  emit(on_stream_event, started)
66
62
 
67
63
  # The ask comes after tool_call_started: the UI shows the tool line,
@@ -102,8 +98,8 @@ module Samagotchi
102
98
  completed[:diff] = diff if diff
103
99
  completed[:waited_ms] = waited_ms if waited_ms
104
100
  # Also here: a UI that missed the start (a replay gap) builds its row
105
- # from this event.
106
- completed[:view] = view if view
101
+ # from this event (its title is in the activity).
102
+ completed.merge!(fields.slice(*ToolRowFields::EVENT_KEYS))
107
103
  emit(on_stream_event, completed)
108
104
  # After the completed event: a hook's card (check-in, skills) prints
109
105
  # under the call's tool row, not before it.
@@ -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).
@@ -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.54.0"
4
+ VERSION = "0.56.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"
@@ -40,6 +41,7 @@ require_relative "../context_providers"
40
41
  require_relative "../recap_store"
41
42
  require_relative "markdown_renderer"
42
43
  require_relative "capabilities"
44
+ require_relative "editor"
43
45
  require_relative "message_parts"
44
46
  require_relative "session_summary"
45
47
  require_relative "../log"
@@ -88,6 +90,9 @@ module Samagotchi
88
90
  # @param annotate_presets [String, Array] the quick replies next to
89
91
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
90
92
  # page parses them (annotate_presets.js).
93
+ # @param editor [String] what a ref opens on this machine (web.editor:
94
+ # a preset or a URL template, Web::Editor); only a local viewer gets
95
+ # it (#viewer_editor)
91
96
  # @param hub [SessionHub, nil] the session projection GET /api/sessions
92
97
  # lists and GET /api/events streams from (Server always builds one);
93
98
  # without one both routes answer 503
@@ -102,6 +107,7 @@ module Samagotchi
102
107
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
103
108
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
104
109
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
110
+ editor: Config::BY_KEY["web.editor"].default,
105
111
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
106
112
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
107
113
  @manager = manager || SessionManager
@@ -117,6 +123,7 @@ module Samagotchi
117
123
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
118
124
  @view = view
119
125
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
126
+ @editor_url = Editor.template(editor)
120
127
  @hub = hub
121
128
  @events_heartbeat = events_heartbeat
122
129
  @events_queue = events_queue
@@ -212,6 +219,23 @@ module Samagotchi
212
219
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
213
220
  end
214
221
 
222
+ # The payload's capabilities (Capabilities, per request) and, next to
223
+ # them, editor_url: the URL template a ref opens (web.editor), nil
224
+ # when this viewer can't open one.
225
+ def viewer_capabilities(req)
226
+ editor_url = @editor_url && local_viewer?(req) ? @editor_url : nil
227
+ { capabilities: Capabilities.for(@markdown_renderer, editor: !editor_url.nil?).to_h, editor_url: editor_url }
228
+ end
229
+
230
+ # A viewer on this machine, whose editor can open the session's
231
+ # paths: a loopback peer, or this Mac on its own LAN address (the LAN
232
+ # URL or its QR code opened here). The phone isn't. An SSH tunnel or
233
+ # a local reverse proxy looks like loopback too (a known gap).
234
+ def local_viewer?(req)
235
+ peer = req.get_header("REMOTE_ADDR").to_s
236
+ LOOPBACK_PEERS.include?(peer) || (!@lan.nil? && peer == @lan[:ip])
237
+ end
238
+
215
239
  LOOPBACK_NAMES = %w[127.0.0.1 [::1] localhost].freeze
216
240
  LOOPBACK_PEERS = %w[127.0.0.1 ::1 ::ffff:127.0.0.1].freeze
217
241
 
@@ -494,7 +518,7 @@ module Samagotchi
494
518
  end
495
519
 
496
520
  # The thinking level +model+ starts at, with where it came from (the
497
- # start page's think chip; Thinking::Explained#summary, no session).
521
+ # start page's think chip; SettingExplained#summary, no session).
498
522
  def add_thinking(model, target, names, settings)
499
523
  model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
500
524
  rescue StandardError
@@ -538,7 +562,7 @@ module Samagotchi
538
562
  end
539
563
 
540
564
  # A session's thinking level with where it came from and its own
541
- # (Thinking::Explained#summary), as its worker would resolve it; nil
565
+ # (SettingExplained#summary), as its worker would resolve it; nil
542
566
  # when it can't be. Config and the registry only: no network.
543
567
  def thinking_for(session)
544
568
  registry = host_registry
@@ -550,6 +574,22 @@ module Samagotchi
550
574
  nil
551
575
  end
552
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
+
553
593
  # Built again when config.yml's hosts: changed (compared as read: the
554
594
  # YAML read is mtime-cached), so the model picker lists a host added
555
595
  # while chi web runs. An injected registry (specs) stays.
@@ -797,6 +837,10 @@ module Samagotchi
797
837
  session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
798
838
  # The thinking level the next turn runs at (the think chip), the same way.
799
839
  session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
840
+ # Its reasoning history (the reasoning chip): the worker's (nil on a
841
+ # native host), else the server's reading (an older worker, none).
842
+ history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
843
+ session_json = session_json.merge(reasoning_history: history)
800
844
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
801
845
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
802
846
  json_response(200, {
@@ -821,7 +865,7 @@ module Samagotchi
821
865
  # names its plugins' too.
822
866
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
823
867
  markdown_warning: @markdown_renderer.warning,
824
- capabilities: Capabilities.for(@markdown_renderer).to_h,
868
+ **viewer_capabilities(req),
825
869
  pending_question: pending,
826
870
  last_event_seq: last_event_seq,
827
871
  # The stream cursor `<seq>-<epoch>`: a later worker resets it
@@ -881,7 +925,7 @@ module Samagotchi
881
925
  session: session_json,
882
926
  messages: messages,
883
927
  markdown_warning: @markdown_renderer.warning,
884
- capabilities: Capabilities.for(@markdown_renderer).to_h,
928
+ **viewer_capabilities(req),
885
929
  timing: timing
886
930
  })
887
931
  rescue ArgumentError => e
@@ -3,16 +3,20 @@
3
3
  module Samagotchi
4
4
  module Web
5
5
  # What this web page renders that a notice may stand in for
6
- # (Engine#hook_notify's fallback_for:). The session and tail JSON carry
7
- # it as +capabilities+; the page leaves out a notice whose fallback_for
8
- # names a capability that is true here. +display+: the answer's display
9
- # is rendered as markdown, so its links are links (web.markdown on and
10
- # commonmarker installed). A later capability is a new member. (The
11
- # member hides Object#display, which nothing calls on it.)
12
- Capabilities = Data.define(:display) do # rubocop:disable Lint/DataDefineOverride
6
+ # (Engine#hook_notify's fallback_for:), and what this viewer can do. The
7
+ # session and tail JSON carry it as +capabilities+; the page leaves out
8
+ # a notice whose fallback_for names a capability that is true here.
9
+ # +display+: the answer's display is rendered as markdown, so its links
10
+ # are links (web.markdown on and commonmarker installed). +editor+: this
11
+ # viewer can open a path in this machine's editor (a viewer on this
12
+ # machine, web.editor not none); per request, the URL template goes next
13
+ # to it as the payload's editor_url. A later capability is a new member.
14
+ # (The member hides Object#display, which nothing calls on it.)
15
+ Capabilities = Data.define(:display, :editor) do # rubocop:disable Lint/DataDefineOverride
13
16
  # @param markdown_renderer [MarkdownRenderer]
14
- def self.for(markdown_renderer)
15
- new(display: markdown_renderer.available? == true)
17
+ # @param editor [Boolean] this request's viewer can open refs
18
+ def self.for(markdown_renderer, editor: false)
19
+ new(display: markdown_renderer.available? == true, editor: editor == true)
16
20
  end
17
21
  end
18
22
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../log"
4
+
5
+ module Samagotchi
6
+ module Web
7
+ # web.editor: the URL a ref's click opens in this machine's editor, as a
8
+ # template with {path} (the file's absolute path, each segment encoded
9
+ # by the page) and an optional {line}. A preset name, or a template of
10
+ # one's own; "none" (or an unknown name) turns refs off.
11
+ module Editor
12
+ PRESETS = {
13
+ "vscode" => "vscode://file{path}:{line}",
14
+ "vscode-insiders" => "vscode-insiders://file{path}:{line}",
15
+ "cursor" => "cursor://file{path}:{line}",
16
+ "zed" => "zed://file{path}:{line}"
17
+ }.freeze
18
+ NONE = "none"
19
+
20
+ module_function
21
+
22
+ # @param value [String, nil] the web.editor setting
23
+ # @return [String, nil] the URL template; nil for none. A value that
24
+ # is neither a preset nor has {path} logs a warning and is none.
25
+ def template(value)
26
+ text = value.to_s.strip
27
+ return text if text.include?("{path}")
28
+
29
+ name = text.downcase
30
+ return nil if name.empty? || name == NONE
31
+ return PRESETS[name] if PRESETS.key?(name)
32
+
33
+ Log.warn(:web, "editor_unknown", value: text)
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
@@ -3,7 +3,7 @@
3
3
  require_relative "../model_profile"
4
4
  require_relative "../tool_call_parser"
5
5
  require_relative "../tool_activity"
6
- require_relative "../tool_view"
6
+ require_relative "../tool_row_fields"
7
7
  require_relative "../llm/native_tool_normalizer"
8
8
  require_relative "../tools/builtins"
9
9
  require_relative "../image_store"
@@ -231,15 +231,10 @@ module Samagotchi
231
231
  name = call[:name].to_s
232
232
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
233
233
  part = { tool: name, params: params.to_s }
234
- title = ToolActivity.tool_title(name, call, cwd: cwd)
235
- part[:title] = title if title
236
- # The name the model called it by when that was an alias's (the
237
- # saved call is rebuilt by BuiltinCalls, which sets it again).
238
- part[:called_as] = call[:called_as] if call[:called_as]
239
- # Built from the saved call (never saved itself), so older sessions
240
- # get it too.
241
- view = ToolView.for(name, call)&.to_h
242
- part[:view] = view if view
234
+ # Its row fields (ToolRowFields: title, called_as, view, ref), built from the saved
235
+ # call (never saved themselves), so older sessions get them too. The
236
+ # saved call is rebuilt by BuiltinCalls, which sets called_as again.
237
+ part.merge!(ToolRowFields.for(name, call, cwd: cwd))
243
238
  part[:label] = label if label.is_a?(String) && !label.empty?
244
239
  unless output.nil?
245
240
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output