samagotchi 0.131.0 → 0.132.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +32 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +62 -6
  7. data/docs/desktop.md +15 -6
  8. data/docs/guardrails.md +3 -3
  9. data/docs/internals/anthropic.md +3 -2
  10. data/docs/internals/engine.md +4 -4
  11. data/docs/internals/prompt-caching.md +26 -6
  12. data/docs/memory.md +16 -2
  13. data/docs/plugins.md +68 -1
  14. data/lib/samagotchi/bootstrap_command.rb +35 -6
  15. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  16. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  17. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/cache_confirm.rb +18 -0
  20. data/lib/samagotchi/chi_home.rb +311 -0
  21. data/lib/samagotchi/desktop/macos.rb +75 -19
  22. data/lib/samagotchi/engine.rb +35 -4
  23. data/lib/samagotchi/engine_snapshots.rb +5 -0
  24. data/lib/samagotchi/gem_update.rb +19 -10
  25. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  26. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  27. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  28. data/lib/samagotchi/home_command.rb +149 -0
  29. data/lib/samagotchi/installed_versions.rb +5 -3
  30. data/lib/samagotchi/llm_context_command.rb +53 -7
  31. data/lib/samagotchi/llm_context_override.rb +3 -2
  32. data/lib/samagotchi/plugin/api.rb +45 -1
  33. data/lib/samagotchi/plugin/context.rb +34 -1
  34. data/lib/samagotchi/plugin/loader.rb +5 -2
  35. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  36. data/lib/samagotchi/run_options.rb +1 -0
  37. data/lib/samagotchi/session_access/contract.rb +1 -1
  38. data/lib/samagotchi/session_commands.rb +64 -17
  39. data/lib/samagotchi/session_manager.rb +36 -1
  40. data/lib/samagotchi/session_model_settings.rb +5 -2
  41. data/lib/samagotchi/system_prompt.rb +34 -3
  42. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  43. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  44. data/lib/samagotchi/testing/bundle.rb +22 -3
  45. data/lib/samagotchi/testing/host.rb +22 -1
  46. data/lib/samagotchi/testing/recorder.rb +6 -1
  47. data/lib/samagotchi/testing/recording_api.rb +1 -1
  48. data/lib/samagotchi/update_command.rb +65 -7
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +11 -1
  51. data/lib/samagotchi/web/public/index.html +6 -2
  52. data/lib/samagotchi/web/public/llm_context_chip.js +4 -3
  53. data/lib/samagotchi/web/public/model_pick.js +26 -0
  54. data/lib/samagotchi/web/public/model_picker.js +3 -1
  55. data/lib/samagotchi/web/public/start_page.js +14 -2
  56. data/lib/samagotchi/web/server.rb +27 -8
  57. data/lib/samagotchi/web/update_check.rb +3 -2
  58. metadata +5 -1
@@ -14,6 +14,7 @@ require_relative "memory_bundle/index_size"
14
14
  require_relative "thinking"
15
15
  require_relative "prompt_files"
16
16
  require_relative "participant_mode"
17
+ require_relative "plugin/prompt_sections"
17
18
 
18
19
  module Samagotchi
19
20
  # The system prompt an Engine gives its model: the base prompt (tool
@@ -57,9 +58,12 @@ module Samagotchi
57
58
  # @param participants [#call] → ParticipantMode::PAIR / REVIEW while the
58
59
  # session has participants (SessionParticipants#prompt_mode; true reads
59
60
  # as review), nil or false with none (the #participants_section line)
61
+ # @param sections [#call] → Array<Plugin::PromptSections::Built>, the
62
+ # plugins' prompt sections (chi.prompt_section) run for this build
60
63
  def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
61
- muted_memory_names: [], appended_files: [], participants: -> { false })
64
+ muted_memory_names: [], appended_files: [], participants: -> { false }, sections: -> { [] })
62
65
  @profile_lookup = profile
66
+ @sections_lookup = sections
63
67
  @participants_lookup = participants
64
68
  @model_lookup = model
65
69
  @model_notes_lookup = model_notes
@@ -97,19 +101,23 @@ module Samagotchi
97
101
  @index_sizes ||= {}
98
102
  @notes_loaded ||= {}
99
103
  @files_loaded ||= {}
104
+ @sections_loaded ||= {}
100
105
  unless @built.key?(key)
101
106
  @section_notes = nil
102
107
  @section_files = []
108
+ @section_plugins = []
103
109
  @built[key] = system_prompt_with_index(
104
110
  assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
105
111
  )
106
112
  @index_sizes[key] = @measured_index
107
113
  @notes_loaded[key] = @section_notes || []
108
114
  @files_loaded[key] = @section_files.freeze
115
+ @sections_loaded[key] = @section_plugins.freeze
109
116
  end
110
117
  @memory_index = @index_sizes[key]
111
118
  @prompt_notes = @notes_loaded[key]
112
119
  @prompt_files = @files_loaded[key]
120
+ @prompt_sections = @sections_loaded[key]
113
121
  @built[key]
114
122
  end
115
123
 
@@ -135,6 +143,12 @@ module Samagotchi
135
143
  # @return [Array<String>]
136
144
  def prompt_files = @prompt_files || []
137
145
 
146
+ # The plugins' prompt sections the prompt #build returned last holds
147
+ # (Plugin::PromptSections::Built, prompt order), [] before a build and
148
+ # after #reset!.
149
+ # @return [Array<Plugin::PromptSections::Built>]
150
+ def prompt_sections = @prompt_sections || []
151
+
138
152
  # Drops the built prompts: the next #build reads the profile, tools,
139
153
  # indexes and memories again (a model or profile switch, changed tools).
140
154
  def reset!
@@ -144,6 +158,8 @@ module Samagotchi
144
158
  @prompt_notes = nil
145
159
  @files_loaded = nil
146
160
  @prompt_files = nil
161
+ @sections_loaded = nil
162
+ @prompt_sections = nil
147
163
  @stable_lengths = nil
148
164
  end
149
165
 
@@ -298,7 +314,8 @@ module Samagotchi
298
314
  # prefix): what every session shares first, what changes least before
299
315
  # what changes more, and the per-session lines last. The global parts
300
316
  # (identity, preloads) come before the project's AGENTS.md, the
301
- # toolkit's prompt text after the appended files, the memory indexes (a
317
+ # toolkit's prompt text after the appended files, then the plugins'
318
+ # prompt sections, the memory indexes (a
302
319
  # memory write changes them) after all of them; the model, the location
303
320
  # and the session close the prompt (Gemma's tool declarations still
304
321
  # follow them).
@@ -311,7 +328,7 @@ module Samagotchi
311
328
  "System memories:\n#{indexes[:system]}"
312
329
  ].join("\n\n")
313
330
  stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
314
- project_specific_description, appended_section, kit_prompt_section, memory_sections]
331
+ project_specific_description, appended_section, kit_prompt_section, plugin_prompt_section, memory_sections]
315
332
  stable_text = stable.compact.join("\n")
316
333
  volatile = [participants_section, current_model, project_location, current_session].compact
317
334
  return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
@@ -362,6 +379,20 @@ module Samagotchi
362
379
  registry.respond_to?(:toolkit) ? registry.toolkit&.prompt : nil
363
380
  end
364
381
 
382
+ # The plugins' prompt sections (chi.prompt_section), right after the
383
+ # kit prompt and before the memory indexes: a plugin's text depends on
384
+ # its settings and the model, not on a memory write. Each is
385
+ # "From <bundle> (<name>):\n<text>", sorted by (bundle, name)
386
+ # (Plugin::PromptSections). nil with none: the prompt is what it was
387
+ # before prompt sections.
388
+ def plugin_prompt_section
389
+ built = Array(@sections_lookup.call)
390
+ @section_plugins = built
391
+ Plugin::PromptSections.text(built)
392
+ rescue StandardError
393
+ nil
394
+ end
395
+
365
396
  def with_tail_declarations(prompt, chat:, layers:)
366
397
  chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
367
398
  end
@@ -87,9 +87,10 @@ module Samagotchi
87
87
  toolkit_flags: toolkit_flags, settings: settings, parent_answers: parent_answers)
88
88
  end
89
89
  commands = []
90
- commands << "/model #{model}" if model && (attach || resume)
90
+ # --yes on /model and /thinking: typing the flag is the choice (no
91
+ # confirm step); its applied reply ends the launch's command.
92
+ commands << "/model #{model} --yes" if model && (attach || resume)
91
93
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
92
- # --yes: typing the flag is the choice (no confirm step).
93
94
  commands << "#{ThinkingCommand::NAME} #{thinking} --yes" if (attach || resume) && thinking
94
95
  first_command = commands.size > 1 ? commands : commands.first
95
96
  surface = open_surface
@@ -728,6 +728,9 @@ module Samagotchi
728
728
  # The files whose text is in the prompt (SystemPrompt#prompt_files).
729
729
  files = prompt_files_text(snapshot[:prompt_files])
730
730
  lines << "prompt files: #{files}" if files
731
+ # The plugins' prompt sections it holds (chi.prompt_section): name (bundle).
732
+ sections = Array(snapshot[:prompt_sections]).map { |item| "#{item[:name]} (#{item[:bundle]})" }
733
+ lines << "prompt sections: #{sections.join(", ")}" unless sections.empty?
731
734
  # The toolkit's prompt text (Engine#stats_snapshot's kit_prompt).
732
735
  if (kit = snapshot[:kit_prompt])
733
736
  lines << "kit prompt: #{kit[:kit]} (~#{kit[:tokens]} tokens)"
@@ -101,7 +101,7 @@ module Samagotchi
101
101
  Plugin::Registries.new(
102
102
  commands: SessionCommands.register_builtins(Commands::Registry.new), tools: Tools::Builtins.registry,
103
103
  services: Plugin::Services.new, stage_tools: ->(*) {}, toolkits: [], tool_index: Tools::ToolIndex.new,
104
- peer_sources: PeerSources.new, **fields
104
+ peer_sources: PeerSources.new, prompt_sections: Plugin::PromptSections.new, **fields
105
105
  )
106
106
  end
107
107
  end
@@ -138,6 +138,9 @@ module Samagotchi
138
138
  attr_reader :errors
139
139
  # @return [Integer] how often chi was told the tools changed
140
140
  attr_reader :tools_changes
141
+ # @return [Integer] how often chi was told the prompt sections changed
142
+ # (chi.prompt_changed!)
143
+ attr_reader :prompt_changes
141
144
 
142
145
  def initialize(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
143
146
  @dir = File.expand_path(dir)
@@ -163,6 +166,7 @@ module Samagotchi
163
166
  @errors = []
164
167
  @lenient = false
165
168
  @tools_changes = 0
169
+ @prompt_changes = 0
166
170
  @contexts = {}
167
171
  @registries = build_registries
168
172
  end
@@ -261,12 +265,16 @@ module Samagotchi
261
265
  # Run a command line ("/name args") as chi does: the first matching
262
266
  # command, given the text after its name. Returns the handler's value
263
267
  # (a raise propagates: chi would show it as an error result).
264
- def command(line)
268
+ # @param confirm [Boolean] sent by a person at a web tab or a TUI, who
269
+ # can answer ctx.cache_confirm (false: chi send, a delegate); a line
270
+ # with --yes never asks, as in chi
271
+ def command(line, confirm: false)
265
272
  entry = commands.lookup(line)
266
273
  raise ArgumentError, "no command matches #{line.inspect}" unless entry
267
274
  raise ArgumentError, "#{entry.name} is chi's own command, not the bundle's" if entry.source == "core"
268
275
 
269
- failing { entry.handler.call(line.to_s.strip.delete_prefix(entry.name).strip) }
276
+ args = line.to_s.strip.delete_prefix(entry.name).strip
277
+ failing { CacheConfirm.asking(confirm && !CacheConfirm.split(args).last) { entry.handler.call(args) } }
270
278
  end
271
279
 
272
280
  # Call a registered tool as chi's dispatch does: its handler gets the
@@ -324,6 +332,16 @@ module Samagotchi
324
332
  # @return [PeerSources]
325
333
  def peer_sources = @registries.peer_sources
326
334
 
335
+ # The prompt sections as chi's next prompt build runs them, for the
336
+ # session now (its id: a failure keeps that session's last good text).
337
+ # @return [Array<Plugin::PromptSections::Built>]
338
+ def prompt_sections = @registries.prompt_sections.render(@recorder[:session_id])
339
+
340
+ # The text they add to the system prompt, as chi puts it there; nil
341
+ # with none.
342
+ # @return [String, nil]
343
+ def prompt_text = Plugin::PromptSections.text(prompt_sections)
344
+
327
345
  # The bundle's own command entries.
328
346
  def bundle_commands = commands.entries.select { |entry| entry.source == @name }
329
347
  # The bundle's own tool entries.
@@ -401,6 +419,7 @@ module Samagotchi
401
419
  env: ENV, sessions: @sessions, http_transport: @http)
402
420
  },
403
421
  tools_changed: -> { @tools_changes += 1 },
422
+ prompt_changed: -> { @prompt_changes += 1 },
404
423
  stage_tools: ->(_bundle, specs, context) { @staged_tools << StagedTools.new(specs: specs, context: context) },
405
424
  init: lambda { |_bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
406
425
  @inits << Init.new(label: label, plugin_label: plugin_label, provides_tools: provides_tools, quiet: quiet,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../cancellation_controller"
4
+ require_relative "../cache_confirm"
4
5
  require_relative "../plugin/context"
5
6
  require_relative "../session_metrics/cost"
6
7
  require_relative "../session_metrics/usage"
@@ -12,6 +13,14 @@ module Samagotchi
12
13
  # with every member set, its values read from the Recorder at each call
13
14
  # and its actions landing in it.
14
15
  module Host
16
+ # What CacheConfirm.ask reads of an Engine, from the Recorder's
17
+ # history: and context_tokens:.
18
+ CacheEngine = Struct.new(:history, :tokens) do
19
+ def history? = history ? true : false
20
+ def cache_restart_tokens = tokens
21
+ def cache_restart_anthropic? = false
22
+ end
23
+
15
24
  module_function
16
25
 
17
26
  # @param recorder [Recorder]
@@ -37,6 +46,7 @@ module Samagotchi
37
46
  },
38
47
  model_name: -> { recorder[:model_name] },
39
48
  model_key: -> { recorder[:model_key] },
49
+ model_names: -> { recorder[:model_names] || model_names(recorder[:model_name]) },
40
50
  state_dir: -> { recorder[:state_dir] || Session.default_state_dir },
41
51
  scratch: -> { recorder[:scratch] },
42
52
  setup: -> { recorder[:setup] },
@@ -52,10 +62,21 @@ module Samagotchi
52
62
  cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
53
63
  cost: -> { cost(recorder[:cost]) },
54
64
  usage: -> { usage(recorder[:usage]) },
55
- turn_author: -> { recorder[:turn_author] }
65
+ turn_author: -> { recorder[:turn_author] },
66
+ cache_confirm: lambda { |line, changed|
67
+ CacheConfirm.ask(line: line, cost: :full, changed: changed,
68
+ engine: CacheEngine.new(recorder[:history], recorder[:context_tokens]))
69
+ }
56
70
  )
57
71
  end
58
72
 
73
+ # ctx.model_names for a model ref when the test sets none: the ref,
74
+ # then its id without the host ("test:model" → test:model, model).
75
+ def model_names(ref)
76
+ ref = ref.to_s
77
+ [ref, ref.split(":", 2).last].reject(&:empty?).uniq
78
+ end
79
+
59
80
  # The parts a test may give bundle.set(cost: {...}).
60
81
  COST_PARTS = %i[reported estimated side known].freeze
61
82
 
@@ -43,7 +43,12 @@ module Samagotchi
43
43
  # callable giving one.
44
44
  usage: nil,
45
45
  # ctx.turn_author and the hooks' author: (a participant's "p:<id>"), nil for the owner.
46
- turn_author: nil
46
+ turn_author: nil,
47
+ # ctx.cache_confirm: whether the session has a model reply (a cache
48
+ # to restart), and the context size its confirm names (nil: none).
49
+ history: false, context_tokens: nil,
50
+ # ctx.model_names: nil gives model_name and its id without the host.
51
+ model_names: nil
47
52
  }.freeze
48
53
 
49
54
  attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
@@ -11,7 +11,7 @@ module Samagotchi
11
11
  # The real Plugin::Api, also recording each declaration in order
12
12
  # (#declarations), for a spec of what #register declares as written.
13
13
  class RecordingApi < Plugin::Api
14
- DECLARATIONS = %i[command annotation tool on service init toolkit tool_source peer_source].freeze
14
+ DECLARATIONS = %i[command annotation tool on service init toolkit tool_source peer_source prompt_section].freeze
15
15
 
16
16
  # @return [Array<Declaration>]
17
17
  def declarations = (@declarations ||= [])
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "cli/command"
4
4
  require_relative "cli/flags"
5
+ require_relative "chi_home"
5
6
  require_relative "config"
6
7
  require_relative "desktop"
7
8
  require_relative "gem_update"
@@ -57,6 +58,10 @@ module Samagotchi
57
58
  # Hidden: the old version, passed by the chi that installed this one.
58
59
  f.value "--gem-from"
59
60
  end
61
+ # "1" in a chi update another chi exec'd into the chi home (#to_home):
62
+ # it doesn't exec again. An env marker, not a flag: the home's chi may
63
+ # be older than the one exec'ing it, and refuse a flag it doesn't know.
64
+ IN_HOME_ENV = "SAMAGOTCHI_UPDATE_IN_HOME"
60
65
  # The switches the newer gem's update gets again (hand_over).
61
66
  HANDED_OVER = { "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
62
67
  "--no-register" => :no_register, "--to" => :to, "--json" => :json }.freeze
@@ -73,11 +78,16 @@ module Samagotchi
73
78
  # @param platform [#call] register: → a Desktop::MacOS-like object
74
79
  # @param exec [#call] argv → replaces this process (the new chi's update)
75
80
  # @param bundler [Boolean] running under Bundler (bundle exec)
81
+ # @param home [#current, #record_first!] the chi home (ChiHome)
82
+ # @param env [Hash] IN_HOME_ENV is taken out of it
76
83
  def initialize(argv, stdout: $stdout, stderr: $stderr, gem_spec: InstalledGem.spec,
77
84
  shipped_dir: MemoryBundle::SourceNormalizer::SHIPPED_DIR, platform: nil,
78
85
  supported: Desktop.supported?, state_dir: Session.default_state_dir, web: nil,
79
- gem_update: GemUpdate.new, exec: nil, bundler: nil)
80
- @gem_update = gem_update
86
+ gem_update: nil, exec: nil, bundler: nil, home: ChiHome, env: ENV)
87
+ @home = home
88
+ @in_home = env.delete(IN_HOME_ENV) == "1"
89
+ # Built after the home is known: its install env goes into the home.
90
+ @gem_update_given = gem_update
81
91
  @exec = exec || ->(argv) { Kernel.exec(*argv) }
82
92
  @bundler = bundler.nil? ? (defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty?) : bundler
83
93
  @argv = argv.dup
@@ -102,6 +112,11 @@ module Samagotchi
102
112
  end
103
113
  return usage_error("invalid version #{options[:to]}") if options[:to] && !Gem::Version.correct?(options[:to])
104
114
 
115
+ # The home's chi decides the rest (--to's floor included); never from
116
+ # a checkout (ChiHome.current is nil there).
117
+ @run_home = @gem_spec && @home.current(gem_spec: @gem_spec)
118
+ return to_home(options) if away_from_home?(options)
119
+
105
120
  if options[:to] && !options[:gem_from] && Gem::Version.new(options[:to]) <= Gem::Version.new(VERSION)
106
121
  @stderr.puts("chi update: #{options[:to]} is not newer than the running chi (#{VERSION})")
107
122
  return 1
@@ -113,6 +128,12 @@ module Samagotchi
113
128
  return 1
114
129
  end
115
130
 
131
+ # No home yet: the first record, once this chi's own checks passed.
132
+ @run_home ||= record_home(options)
133
+ return to_home(options) if away_from_home?(options)
134
+
135
+ @gem_update = @gem_update_given ||
136
+ GemUpdate.new(gem_bin: ChiHome.gem_bin(@run_home), install_env: ChiHome.install_env(@run_home))
116
137
  @dry_run = options[:dry_run]
117
138
  gem = gem_row(options)
118
139
  rows = [gem, system_bundle_row, *bundle_rows(options), desktop_row(options), *live_rows].compact
@@ -130,6 +151,42 @@ module Samagotchi
130
151
 
131
152
  def command_name = "chi update"
132
153
 
154
+ # Whether this run is on another Ruby than the chi home's, and not one
155
+ # another chi exec'd or handed over to.
156
+ def away_from_home?(options) = @run_home && !@in_home && !options[:gem_from] && !@run_home.running_here?
157
+
158
+ # The first chi home (ChiHome.record_first!: the helper's Ruby or this
159
+ # one, whichever has the newer chi), said once. Not in a dry run, a run
160
+ # another chi exec'd or handed over to, nor under Bundler.
161
+ def record_home(options)
162
+ return nil if options[:dry_run] || @in_home || options[:gem_from] || @bundler
163
+
164
+ recorded = @home.record_first!(gem_spec: @gem_spec, launch_path: helper_launch_path, bundler: false)
165
+ notice(options, "chi home set to #{recorded.ruby} (chi home set to change)") if recorded
166
+ recorded
167
+ end
168
+
169
+ # A chi on another Ruby than the home's updates the home: exec its chi
170
+ # (newer?, --to and the bundle rows are then the home's), once
171
+ # (IN_HOME_ENV).
172
+ def to_home(options)
173
+ notice(options, "updating the chi home (#{@run_home.ruby})")
174
+ @stdout.flush
175
+ @exec.call([{ IN_HOME_ENV => "1" }, *@run_home.boot_argv(run: ChiHome::CHI_BOOT), "--", "update", *@argv])
176
+ 0 # only when exec is a spec's stand-in
177
+ end
178
+
179
+ def notice(options, text) = (options[:json] ? @stderr : @stdout).puts(text)
180
+
181
+ def helper_launch_path
182
+ return nil unless @supported
183
+
184
+ helper = @platform.call(register: false)
185
+ helper.respond_to?(:launch_path) ? helper.launch_path : nil
186
+ rescue StandardError
187
+ nil
188
+ end
189
+
133
190
  # @return [Hash, Integer] the options, or the usage error's exit status
134
191
  # (every error reads "unknown option", a --gem-from without its value too)
135
192
  def parse
@@ -181,16 +238,17 @@ module Samagotchi
181
238
  hand_over(options, row)
182
239
  end
183
240
 
184
- # exec the wrapper, which activates the newest installed samagotchi.
185
- # Without one (installed with --bindir), this version syncs.
241
+ # exec the newest installed samagotchi: the chi home's boot, else the
242
+ # wrapper. Without either (installed with --bindir), this version syncs.
186
243
  def hand_over(options, row)
187
- wrapper = InstalledGem.wrapper(@gem_spec)
188
- return done(row, :update, "run chi update again to sync with it") unless wrapper
244
+ wrapper = InstalledGem.wrapper(@gem_spec) unless @run_home
245
+ return done(row, :update, "run chi update again to sync with it") unless @run_home || wrapper
189
246
 
190
247
  passed = options[:to] ? ["--to", options[:to]] : []
191
248
  passed += HANDED_OVER.except("--to").select { |_, key| options[key] }.keys
249
+ args = ["update", "--no-gem", "--gem-from", VERSION, *passed]
192
250
  @stdout.flush
193
- @exec.call([RbConfig.ruby, wrapper, "update", "--no-gem", "--gem-from", VERSION, *passed])
251
+ @exec.call(@run_home ? @run_home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *args] : [RbConfig.ruby, wrapper, *args])
194
252
  done(row, :update) # only when exec is a spec's stand-in
195
253
  end
196
254
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.131.0"
4
+ VERSION = "0.132.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -78,6 +78,7 @@ require_relative "proxy_gate"
78
78
  require_relative "../log"
79
79
  require_relative "../paths"
80
80
  require_relative "../redacted_inspect"
81
+ require_relative "../chi_home"
81
82
  require_relative "../child_env"
82
83
  require_relative "../installed_gem"
83
84
  require_relative "../live_versions"
@@ -759,8 +760,17 @@ module Samagotchi
759
760
  error_response(503, "update_start_failed", "couldn't start chi update")
760
761
  end
761
762
 
763
+ # The chi update child: with a chi home, the home's chi (its gem dirs
764
+ # are what new workers activate); else this Ruby and the wrapper.
765
+ def update_argv(version, home: ChiHome.current)
766
+ args = ["update", "--to", version, "--no-desktop", "--json"]
767
+ return home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *args] if home
768
+
769
+ [RbConfig.ruby, InstalledGem.wrapper, *args]
770
+ end
771
+
762
772
  def perform_update(version)
763
- argv = [RbConfig.ruby, InstalledGem.wrapper, "update", "--to", version, "--no-desktop", "--json"]
773
+ argv = update_argv(version)
764
774
  said = nil
765
775
  output, success = @update_runner.call(argv, env: ChildEnv.unbundled,
766
776
  progress: lambda { |line|
@@ -607,6 +607,10 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
607
607
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
608
608
  /* The model picker (the start page, and a fresh chain link: start_page.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
609
609
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
610
+ /* The start page's row stays one row (Start never wraps): the model name gives way, the chips keep their whole text (on a phone, below, they ellipsize too). Scoped off the session composer and a fresh link's button. */
611
+ body:not(.has-session) #composer .actions-col{flex-wrap:nowrap}
612
+ body:not(.has-session) #composer .actions-col .model-pick{flex:0 1 auto;min-width:0}
613
+ body:not(.has-session) #composer .actions-col .llmctx-new{flex-shrink:0}
610
614
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
611
615
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
612
616
  .model-pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -623,8 +627,8 @@ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){dis
623
627
  #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
624
628
  /* The new session's thinking chip, after it: shorter text, so a smaller share of the row. */
625
629
  #composer .actions-col .think-new{max-width:min(25%,140px)}
626
- /* Phones (the key hint is gone there): the chips keep their whole text and the row wraps, Start staying right (its margin-left:auto) on the last line; only a model name wider than the row ellipsizes. */
627
- @media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}#composer .actions-col .llmctx-new,#composer .actions-col .think-new{flex:0 0 auto;max-width:100%}}
630
+ /* Phones (the key hint is gone there): still one row, with tighter gaps. The model name gives way first (20x the chips' shrink weight) down to a few characters (min-width), then the chips ellipsize too. */
631
+ @media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}body:not(.has-session) #composer .actions-col{gap:4px}body:not(.has-session) #composer .actions-col .model-pick{flex-shrink:20;min-width:min(84px,20vw)}body:not(.has-session) #composer .actions-col .llmctx-new{flex-shrink:1;max-width:100%}}
628
632
  /* The model search: opaque like #commandList (a list over the conversation
629
633
  must stay readable), above the composer, or below it (.down) when the
630
634
  start page leaves no room above, or over the composer (.over) when
@@ -85,14 +85,15 @@ export function chipTemplate(summary) {
85
85
  // the form opened with) only the fields the user changed go, so a value the
86
86
  // form can't show is never unset by a Set; null when nothing changed.
87
87
  // Without it every field goes, "default" for one left to the model, and all
88
- // of them default is a reset.
88
+ // of them default is a reset. With --yes: the form states what a change
89
+ // costs, so a Set is the choice (no confirm step), as level_chip.js's.
89
90
  export function commandLine({ strategy = DEFAULT, apply = DEFAULT, budget = "" } = {}, initial = null) {
90
91
  const words = { strategy, apply, budget: String(budget).trim() || DEFAULT };
91
92
  const was = initial && { strategy: initial.strategy, apply: initial.apply, budget: String(initial.budget).trim() || DEFAULT };
92
93
  const fields = Object.keys(words).filter((field) => !was || words[field] !== was[field]);
93
94
  if (!fields.length) return null;
94
- if (!was && fields.every((field) => words[field] === DEFAULT)) return "/llm-context reset";
95
- return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")}`;
95
+ if (!was && fields.every((field) => words[field] === DEFAULT)) return "/llm-context reset --yes";
96
+ return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")} --yes`;
96
97
  }
97
98
 
98
99
  // A budget as typed ("64000", "64k", "off", "0") in tokens, 0 for off;
@@ -58,6 +58,32 @@ export function modelRows(payload) {
58
58
  });
59
59
  }
60
60
 
61
+ // A host-less server fallback may still carry "host:org/id".
62
+ function shortNameCandidate(row) {
63
+ const name = String(row?.name || row?.id || "");
64
+ let host = row?.host ? String(row.host) : "";
65
+ let id = String(row?.id || name);
66
+ if (!host) {
67
+ const colon = name.indexOf(":");
68
+ const slash = name.indexOf("/");
69
+ if (colon > 0 && slash > colon) {
70
+ host = name.slice(0, colon);
71
+ id = name.slice(colon + 1);
72
+ }
73
+ }
74
+ const model = id.slice(id.lastIndexOf("/") + 1) || name;
75
+ const labelHost = host && (!row?.isDefaultHost || !row?.host) ? `${host} · ` : "";
76
+ return { name, label: `${labelHost}${model}` };
77
+ }
78
+
79
+ // A compact label for the chosen model; list rows and all stored/API names
80
+ // remain full.
81
+ export function shortName(row, rows = []) {
82
+ const candidate = shortNameCandidate(row);
83
+ const clashes = rows.filter((other) => shortNameCandidate(other).label === candidate.label).length;
84
+ return clashes > 1 ? candidate.name : candidate.label;
85
+ }
86
+
61
87
  const byId = (a, b) => (b.configured === true) - (a.configured === true) ||
62
88
  a.id.localeCompare(b.id, "en", { sensitivity: "base" });
63
89
 
@@ -13,6 +13,7 @@ import { moveSelection } from "./command_complete.js";
13
13
  import { readStored, writeStored } from "./storage.js";
14
14
  import {
15
15
  MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
16
+ shortName,
16
17
  } from "./model_pick.js";
17
18
  import { html, nothing, render as draw } from "./vendor/lit-html.js";
18
19
 
@@ -63,7 +64,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
63
64
  // app.js', carries the host's warning).
64
65
  function setChosen(name) {
65
66
  chosen = name;
66
- button.querySelector(".model-pick-name").textContent = name;
67
+ const row = rows.find((item) => item.name === name);
68
+ button.querySelector(".model-pick-name").textContent = row ? shortName(row, rows) : name;
67
69
  button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
68
70
  }
69
71
 
@@ -131,6 +131,7 @@ export function createStartPage({
131
131
  else {
132
132
  renderNewChatLlmContext();
133
133
  renderNewChatThinking();
134
+ syncStartPickLabel();
134
135
  }
135
136
  focusPrompt();
136
137
  },
@@ -154,6 +155,12 @@ export function createStartPage({
154
155
  pendingPickId = id;
155
156
  pickRanEarly = false;
156
157
  }
158
+ function syncStartPickLabel() {
159
+ const fullName = modelPicker.chosen() || "";
160
+ pickEl.title = fullName ? `${startPickTitle} · ${fullName}` : startPickTitle;
161
+ pickEl.setAttribute("aria-label", fullName
162
+ ? `Model for the new session: ${fullName}` : "Model for the new session");
163
+ }
157
164
  // The button's name, tooltip and label for what the page shows: a fresh
158
165
  // link's model, else the start page's (whose choice stays as it is).
159
166
  // Toggles the fresh-link class (the CSS shows the button in a session
@@ -179,8 +186,12 @@ export function createStartPage({
179
186
  pickEl.title = "The model this link starts on";
180
187
  pickEl.setAttribute("aria-label", "Model this link starts on");
181
188
  } else {
182
- pickEl.title = startPickTitle;
183
- pickEl.setAttribute("aria-label", "Model for the new session");
189
+ if (open) {
190
+ pickEl.title = startPickTitle;
191
+ pickEl.setAttribute("aria-label", "Model for the new session");
192
+ } else {
193
+ syncStartPickLabel();
194
+ }
184
195
  }
185
196
  }
186
197
  // A command_ran with a model (stream_handlers.js): while a pick is in
@@ -192,6 +203,7 @@ export function createStartPage({
192
203
  // Leaving a session: the start page's remembered model again (no fetch).
193
204
  function restoreStartPick() {
194
205
  modelPicker.restore(readStored(MODEL_KEY));
206
+ syncStartPickLabel();
195
207
  renderNewChatLlmContext();
196
208
  renderNewChatThinking();
197
209
  }
@@ -22,6 +22,7 @@ require_relative "update_check"
22
22
  require_relative "token"
23
23
  require_relative "tunnel"
24
24
  require_relative "../config"
25
+ require_relative "../chi_home"
25
26
  require_relative "../child_env"
26
27
  require_relative "../installed_gem"
27
28
  require_relative "../paths"
@@ -219,25 +220,43 @@ module Samagotchi
219
220
  # No line before the exec: one on a hung-up terminal raises EIO, and
220
221
  # the restart would end in a dead chi web; the log says it.
221
222
  def self.reexec(argv, provider: nil, plan: nil, exec: nil)
222
- program = reexec_program
223
- raise ArgumentError, "RubyGems chi wrapper not found" if program.to_s.empty?
223
+ command = reexec_command(argv)
224
+ raise ArgumentError, "RubyGems chi wrapper not found" unless command
224
225
 
225
226
  env = ChildEnv.unbundled.merge(
226
227
  RESTARTED_ENV => "1",
227
228
  ADOPT_PIDS_ENV => SessionManager.live_spawn_pids.join(",")
228
229
  )
229
- Samagotchi::Log.info(:web, "restart", program: program)
230
+ Samagotchi::Log.info(:web, "restart", program: command[1] == "-e" ? "home:#{command[0]}" : command[1])
230
231
  runner = exec || ->(environment, *args) { Kernel.exec(environment, *args) }
231
- runner.call(env, RbConfig.ruby, program, *argv)
232
+ runner.call(env, *command)
232
233
  rescue SystemCallError, ArgumentError => e
233
234
  tunnel_down(provider, plan) if provider && plan
234
235
  warn "Error: couldn't restart chi web: #{e.message}"
235
236
  1
236
237
  end
237
238
 
238
- # What a restart execs: the installed gem's wrapper (it activates the
239
- # newest installed chi), this bin/chi in a checkout; nil for a gem
240
- # installed without one (--bindir).
239
+ # A restart's argv: with a chi home, its boot (ChiHome::CHI_BOOT: the
240
+ # newest chi in the home; a bare wrapper would activate the newest in
241
+ # whatever GEM_* the env has, off the home); else this Ruby and
242
+ # #reexec_program. A home whose newest chi is older than this one is
243
+ # passed over (logged): the restart argv has this chi's flags, which
244
+ # an older one may not know. nil when there is nothing to exec.
245
+ # @return [Array<String>, nil]
246
+ def self.reexec_command(argv, home: ChiHome.current)
247
+ if home && home.newest_version && home.newest_version < Gem::Version.new(Samagotchi::VERSION)
248
+ Samagotchi::Log.warn(:web, "restart_home_older", home: home.newest_version.to_s, version: Samagotchi::VERSION)
249
+ home = nil
250
+ end
251
+ return home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *argv] if home
252
+
253
+ program = reexec_program
254
+ program.to_s.empty? ? nil : [RbConfig.ruby, program, *argv]
255
+ end
256
+
257
+ # What a restart execs without a chi home: the installed gem's wrapper
258
+ # (it activates the newest installed chi), this bin/chi in a checkout;
259
+ # nil for a gem installed without one (--bindir).
241
260
  def self.reexec_program
242
261
  gem_spec = InstalledGem.spec
243
262
  gem_spec ? InstalledGem.wrapper(gem_spec) : $PROGRAM_NAME
@@ -354,7 +373,7 @@ module Samagotchi
354
373
  app.restart_web = lambda do
355
374
  restart_mutex.synchronize do
356
375
  next false if restart_requested
357
- next :no_wrapper if reexec_program.to_s.empty?
376
+ next :no_wrapper unless reexec_command([])
358
377
 
359
378
  restart_requested = true
360
379
  restart_thread = Thread.new do