samagotchi 0.131.0 → 0.133.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/bin/chi +19 -1
  4. data/docs/architecture.md +59 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +115 -16
  7. data/docs/desktop.md +16 -6
  8. data/docs/guardrails.md +10 -4
  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/docs/sessions.md +60 -6
  15. data/docs/testing.md +1 -1
  16. data/lib/samagotchi/bootstrap_command.rb +35 -6
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
  19. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  20. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  21. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/cache_confirm.rb +18 -0
  24. data/lib/samagotchi/chi_home.rb +311 -0
  25. data/lib/samagotchi/chi_shim.rb +77 -0
  26. data/lib/samagotchi/child_env.rb +5 -3
  27. data/lib/samagotchi/config.rb +2 -0
  28. data/lib/samagotchi/context_fetch.rb +4 -2
  29. data/lib/samagotchi/desktop/macos.rb +100 -18
  30. data/lib/samagotchi/engine.rb +35 -4
  31. data/lib/samagotchi/engine_snapshots.rb +5 -0
  32. data/lib/samagotchi/gem_update.rb +19 -10
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
  35. data/lib/samagotchi/guardrails/pair_command.rb +57 -1
  36. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  37. data/lib/samagotchi/guardrails/review_paths.rb +7 -4
  38. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  39. data/lib/samagotchi/home_command.rb +145 -0
  40. data/lib/samagotchi/home_doctor.rb +177 -0
  41. data/lib/samagotchi/home_notice.rb +80 -0
  42. data/lib/samagotchi/installed_versions.rb +5 -3
  43. data/lib/samagotchi/llm_context_command.rb +53 -7
  44. data/lib/samagotchi/llm_context_override.rb +3 -2
  45. data/lib/samagotchi/plugin/api.rb +45 -1
  46. data/lib/samagotchi/plugin/context.rb +34 -1
  47. data/lib/samagotchi/plugin/loader.rb +5 -2
  48. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  49. data/lib/samagotchi/project_env.rb +360 -0
  50. data/lib/samagotchi/run_options.rb +1 -0
  51. data/lib/samagotchi/self_report.rb +33 -0
  52. data/lib/samagotchi/session_access/contract.rb +1 -1
  53. data/lib/samagotchi/session_commands.rb +68 -19
  54. data/lib/samagotchi/session_manager.rb +54 -1
  55. data/lib/samagotchi/session_model_settings.rb +5 -2
  56. data/lib/samagotchi/system_prompt.rb +34 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  59. data/lib/samagotchi/testing/boundary.rb +3 -1
  60. data/lib/samagotchi/testing/bundle.rb +22 -3
  61. data/lib/samagotchi/testing/host.rb +22 -1
  62. data/lib/samagotchi/testing/recorder.rb +6 -1
  63. data/lib/samagotchi/testing/recording_api.rb +1 -1
  64. data/lib/samagotchi/tools/builtins.rb +13 -2
  65. data/lib/samagotchi/update_command.rb +65 -7
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +61 -1
  68. data/lib/samagotchi/web/public/app.js +4 -1
  69. data/lib/samagotchi/web/public/chip_popover.js +5 -2
  70. data/lib/samagotchi/web/public/composer.css +11 -1
  71. data/lib/samagotchi/web/public/data.js +8 -4
  72. data/lib/samagotchi/web/public/index.html +52 -10
  73. data/lib/samagotchi/web/public/level_chip.js +22 -38
  74. data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
  75. data/lib/samagotchi/web/public/model_pick.js +26 -0
  76. data/lib/samagotchi/web/public/model_picker.js +3 -1
  77. data/lib/samagotchi/web/public/review_start.js +13 -57
  78. data/lib/samagotchi/web/public/send_turn.js +3 -2
  79. data/lib/samagotchi/web/public/start_page.js +131 -99
  80. data/lib/samagotchi/web/public/start_settings.js +298 -0
  81. data/lib/samagotchi/web/public/start_toolkit.js +157 -0
  82. data/lib/samagotchi/web/public/thinking_chip.js +9 -22
  83. data/lib/samagotchi/web/server.rb +27 -8
  84. data/lib/samagotchi/web/update_check.rb +3 -2
  85. metadata +11 -1
@@ -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.133.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -48,6 +48,8 @@ require_relative "../context_sources"
48
48
  require_relative "../context_providers"
49
49
  require_relative "../recap_store"
50
50
  require_relative "../toolkit"
51
+ require_relative "../toolkit_suggest"
52
+ require_relative "../system_one/client"
51
53
  require_relative "../turn_flow"
52
54
  require_relative "../session_chain"
53
55
  require_relative "../session_access"
@@ -78,6 +80,7 @@ require_relative "proxy_gate"
78
80
  require_relative "../log"
79
81
  require_relative "../paths"
80
82
  require_relative "../redacted_inspect"
83
+ require_relative "../chi_home"
81
84
  require_relative "../child_env"
82
85
  require_relative "../installed_gem"
83
86
  require_relative "../live_versions"
@@ -759,8 +762,17 @@ module Samagotchi
759
762
  error_response(503, "update_start_failed", "couldn't start chi update")
760
763
  end
761
764
 
765
+ # The chi update child: with a chi home, the home's chi (its gem dirs
766
+ # are what new workers activate); else this Ruby and the wrapper.
767
+ def update_argv(version, home: ChiHome.current)
768
+ args = ["update", "--to", version, "--no-desktop", "--json"]
769
+ return home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *args] if home
770
+
771
+ [RbConfig.ruby, InstalledGem.wrapper, *args]
772
+ end
773
+
762
774
  def perform_update(version)
763
- argv = [RbConfig.ruby, InstalledGem.wrapper, "update", "--to", version, "--no-desktop", "--json"]
775
+ argv = update_argv(version)
764
776
  said = nil
765
777
  output, success = @update_runner.call(argv, env: ChildEnv.unbundled,
766
778
  progress: lambda { |line|
@@ -1209,9 +1221,57 @@ module Samagotchi
1209
1221
  add_sampling(models, registry)
1210
1222
  payload = { default: default, models: models }
1211
1223
  payload[:warning] = warnings.join("; ") unless warnings.empty?
1224
+ toolkits = toolkits_listing(registry)
1225
+ payload[:toolkits] = toolkits if toolkits
1212
1226
  json_response(200, payload)
1213
1227
  end
1214
1228
 
1229
+ # Kits the web never offers: the participants' boundary, a delegate
1230
+ # child's, and ToolkitSuggest's defer sentinel.
1231
+ PICKER_HIDDEN_KITS = [Toolkit::REVIEW, Toolkit::CHILD, "undecided"].freeze
1232
+
1233
+ # The start page's toolkit picker: {default:, suggest:, kits: [{name,
1234
+ # description?}]}. Built-in and config kits only (a bundle's load in
1235
+ # the worker), less PICKER_HIDDEN_KITS and any kit a new session would
1236
+ # refuse (Toolkit.check_new!) or that doesn't resolve (a base that is a
1237
+ # bundle kit); the configured default always listed, first when it
1238
+ # isn't one of them. +suggest+: "on" only when the worker would ask
1239
+ # (toolkit.suggest.mode on, a System 1 host, at least 2 described
1240
+ # candidates), else the mode ("audit") or "off". nil when it can't be
1241
+ # read: the picker stays hidden.
1242
+ def toolkits_listing(registry)
1243
+ default = Toolkit.configured.name
1244
+ config = Toolkit.config_kits
1245
+ kits = Toolkit.kits(config: config)
1246
+ listed = kits.values.filter_map do |kit|
1247
+ next if PICKER_HIDDEN_KITS.include?(kit.name) || !ToolkitSuggest.resolves?(kit.name, kits)
1248
+ next unless new_session_kit?(kit.name, config)
1249
+
1250
+ kit.description.to_s.empty? ? { name: kit.name } : { name: kit.name, description: kit.description }
1251
+ end
1252
+ listed.unshift({ name: default }) if listed.none? { |kit| kit[:name] == default }
1253
+ { default: default, suggest: toolkit_suggest_mode(registry, kits, default), kits: listed }
1254
+ rescue StandardError => e
1255
+ Log.warn(:web, "toolkits_listing_failed", error: e.class.name, msg: e.message)
1256
+ nil
1257
+ end
1258
+
1259
+ def new_session_kit?(name, config)
1260
+ Toolkit.check_new!(Toolkit.new(name: name), config: config)
1261
+ true
1262
+ rescue Toolkit::Refused
1263
+ false
1264
+ end
1265
+
1266
+ def toolkit_suggest_mode(registry, kits, default)
1267
+ mode = Config.get("toolkit.suggest.mode").to_s.strip.downcase
1268
+ return mode == "audit" ? "audit" : "off" unless mode == "on"
1269
+ return "off" if ToolkitSuggest.candidates(kits, default_name: default).size < 2
1270
+ return "off" unless SystemOne::Client.from_config(host_registry: registry, mode: "on", tag: :toolkit)
1271
+
1272
+ "on"
1273
+ end
1274
+
1215
1275
  # Each model's configured sampling as /model words it ("temperature=0.6
1216
1276
  # (hosts.work)"), on the models that have some: the picker's tooltip;
1217
1277
  # and each model's LLM context (LLMContextStrategy::Explained#summary).
@@ -194,7 +194,7 @@ const participantsControl = createParticipantsControl({
194
194
  onChange: () => updateInfoBar(),
195
195
  toast: (text, action, options) => showToast(text, action, options),
196
196
  });
197
- // The start page's model picker and its LLM context and thinking chips
197
+ // The start page's model picker, its ⚙ panel and 🧰 list with the override chips
198
198
  // (start_page.js); a fresh chain link's picker too.
199
199
  const startPage = createStartPage({
200
200
  pickEl: $("#modelPick"),
@@ -203,9 +203,12 @@ const startPage = createStartPage({
203
203
  listEl: $("#modelList"),
204
204
  composerEl: $("#composer"),
205
205
  centerEl: $("#center"),
206
+ settingsEl: $("#settingsNew"),
206
207
  llmContextEl: $("#llmContextNew"),
207
208
  thinkingEl: $("#thinkingNew"),
208
209
  reviewEl: $("#reviewNew"),
210
+ toolkitBtnEl: $("#toolkitBtn"),
211
+ toolkitEl: $("#toolkitNew"),
209
212
  selected: () => selected,
210
213
  state: () => current,
211
214
  running: () => liveTurn.running,
@@ -45,10 +45,12 @@ export function sheetPlace({ innerWidth, innerHeight }) {
45
45
  // open, else a refresh with a newer summary); onClose(): the caller forgets
46
46
  // its open state; onSubmit(form); onAct(name): a data-act other than close.
47
47
  // sheet: a bottom sheet in a narrow window (sheetPlace). label: the
48
- // dialog's accessible name (aria-label), none by default.
48
+ // dialog's accessible name (aria-label), none by default. onOpen(): after
49
+ // an open has shown and placed it (where focus can go into it: show runs
50
+ // while it is still hidden).
49
51
  // @return {pop, open, close, refresh, isOpen, opener}
50
52
  export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, openers = anchorSel, summary,
51
- session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {},
53
+ session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {}, onOpen = () => {},
52
54
  sheet = false, label = null, doc = document, win = window }) {
53
55
  const pop = doc.createElement("div");
54
56
  pop.id = id;
@@ -93,6 +95,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
93
95
  show(data, true);
94
96
  pop.hidden = false;
95
97
  place(anchor);
98
+ onOpen();
96
99
  }
97
100
 
98
101
  // A closed popover is empty, so each open draws a fresh form (unsent
@@ -5,7 +5,7 @@
5
5
  the / list, and the 16px text fields for iOS. */
6
6
  /* The image button's icon mask (the stroke only shapes the mask; the icon takes currentColor); index.html's phone info
7
7
  bar and all view's cards use it too. */
8
- :root{--icon-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
8
+ :root{--icon-toolkit:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='1.75' y='5.25' width='12.5' height='8' rx='1.5'/%3E%3Cpath d='M5.75 5.25v-1.5a1 1 0 0 1 1-1h2.5a1 1 0 0 1 1 1v1.5M1.75 8.75h12.5M7 8.75v1.5h2v-1.5'/%3E%3C/svg%3E");--icon-settings:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linejoin='round'%3E%3Cpath d='M12.75 6.14L14.45 6.59L14.45 9.41L12.75 9.86L12.67 10.04L13.56 11.56L11.56 13.56L10.04 12.67L9.86 12.75L9.41 14.45L6.59 14.45L6.14 12.75L5.96 12.67L4.44 13.56L2.44 11.56L3.33 10.04L3.25 9.86L1.55 9.41L1.55 6.59L3.25 6.14L3.33 5.96L2.44 4.44L4.44 2.44L5.96 3.33L6.14 3.25L6.59 1.55L9.41 1.55L9.86 3.25L10.04 3.33L11.56 2.44L13.56 4.44L12.67 5.96Z'/%3E%3Ccircle cx='8' cy='8' r='2.1'/%3E%3C/svg%3E");--icon-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
9
9
  /* The composer card: the glass card both pages' composers sit in (the owner's #dock, the stage view's .dock-card, the
10
10
  participant page's .p-card), with its focus ring; its footer (the owner's info bar, the participant's usage) under
11
11
  a hairline; and the composer's line, the quiet row above the text field (the owner's delivery line, the
@@ -73,6 +73,16 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
73
73
  #composer .actions-col .attach-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon-image) center/contain no-repeat;mask:var(--icon-image) center/contain no-repeat}
74
74
  #composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
75
75
  #composer .actions-col .attach-btn[hidden]{display:none}
76
+ /* An icon button on the row (the start page's ⚙ and 🧰, start_settings.js and start_toolkit.js): quiet like the image button, its icon a mask in --icon; before it (order 2, earlier in the markup).
77
+ .set: something under it is set, an accent dot on a phone (the override chips say it on a wider row). */
78
+ #composer .actions-col .icon-btn{order:2;position:relative;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
79
+ #composer .actions-col .icon-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
80
+ #composer .actions-col .icon-btn:hover,#composer .actions-col .icon-btn[aria-expanded="true"]{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
81
+ #composer .actions-col .icon-btn[hidden],body.has-session #composer .actions-col .icon-btn{display:none}
82
+ #composer .actions-col .settings-btn{--icon:var(--icon-settings)}
83
+ #composer .actions-col .toolkit-btn{--icon:var(--icon-toolkit)}
84
+ @media(max-width:600px){#composer .actions-col .icon-btn.set{color:var(--accent)}#composer .actions-col .icon-btn.set::after{content:"";position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--accent)}}
85
+ @media(pointer:coarse){#composer .actions-col .icon-btn{width:36px;height:36px;min-height:36px}}
76
86
  /* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
77
87
  and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
78
88
  body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
@@ -57,13 +57,17 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
57
57
  // it); either left out means the server's default. preview: that message,
58
58
  // which names the session until its turn is saved. llm_context: the
59
59
  // session's own LLM context words ({strategy, apply, budget}), when the
60
- // start page's chip set some; thinking: its own level ("low"), when the
61
- // start page's think chip set one.
60
+ // start page's ⚙ panel set some; thinking: its own level ("low"), when the
61
+ // start page's ⚙ panel set one; toolkit: the kit it starts on, when the
62
+ // start page's 🧰 list picked one (none: config's default, maybe suggested).
62
63
  // review: {subjects} makes it a review session (it starts idle; no preview).
63
- export function createIdleSession({ dir, model, preview, llm_context: llmContext, thinking, review, ...opts } = {}) {
64
+ // The other fields are api()'s fetch options.
65
+ export function createIdleSession({ dir, model, preview, llm_context: llmContext, thinking, toolkit, review,
66
+ ...opts } = {}) {
64
67
  const start = review ? { review } : { idle: true, ...(preview ? { preview } : {}) };
65
68
  const body = { ...start, ...(dir ? { dir } : {}), ...(model ? { model } : {}),
66
- ...(llmContext ? { llm_context: llmContext } : {}), ...(thinking ? { thinking } : {}) };
69
+ ...(llmContext ? { llm_context: llmContext } : {}), ...(thinking ? { thinking } : {}),
70
+ ...(toolkit ? { toolkit } : {}) };
67
71
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
68
72
  }
69
73
 
@@ -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 first, down to its min-width, then the override chips ellipsize (100000x their shrink weight: at 1000x their share of the shrink was still a fraction of a pixel, enough for an ellipsis). 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 100000 auto;min-width:min(84px,20vw)}
613
+ body:not(.has-session) #composer .actions-col .llmctx-new{flex:0 1 auto;min-width:40px}
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}
@@ -616,15 +620,12 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
616
620
  /* A fresh link's (body.fresh-link): the session composer is one row and .actions-col is content-sized there, so a px cap keeps the text field's room and Send in view. :not([hidden]) keeps a failed /api/models' hide (this selector outranks the [hidden] one). */
617
621
  body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){display:inline-block;flex:0 1 auto;min-width:0;max-width:200px}
618
622
  @media(max-width:600px){body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){max-width:100px}}
619
- /* The new session's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
620
- #composer .actions-col .llmctx-new{order:0;flex:0 1 auto;min-width:0;max-width:min(40%,260px);height:24px;min-height:24px;line-height:22px;padding:0 6px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:11px;font-weight:400;overflow:hidden;text-overflow:ellipsis}
621
- #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
622
- #composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
623
+ /* The new session's override chips (start_page.js: what the ⚙ panel has set, only then), right of the key hint next to ⚙: accent; a click opens the panel. A phone has ⚙'s dot instead (below). */
624
+ #composer .actions-col .llmctx-new{order:1;flex:0 1 auto;min-width:0;max-width:min(30%,220px);height:24px;min-height:24px;line-height:22px;padding:0 6px;border-radius:6px;border:1px solid var(--accent-border);background:transparent;color:var(--accent);font-size:11px;font-weight:400;overflow:hidden;text-overflow:ellipsis}
625
+ #composer .actions-col .llmctx-new:hover{filter:none;color:var(--accent);background:var(--accent-bg)}
623
626
  #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
624
- /* The new session's thinking chip, after it: shorter text, so a smaller share of the row. */
625
- #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%}}
627
+ /* Phones (the key hint is gone there): still one row, with tighter gaps; the override chips go (⚙'s dot and label say what is set). */
628
+ @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)}#composer .actions-col .llmctx-new{display:none}}
628
629
  /* The model search: opaque like #commandList (a list over the conversation
629
630
  must stay readable), above the composer, or below it (.down) when the
630
631
  start page leaves no room above, or over the composer (.over) when
@@ -1120,6 +1121,44 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1120
1121
  .pp-working button{font:inherit;font-size:12px;padding:3px 10px;border-radius:6px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
1121
1122
  .pp-working button:disabled{opacity:.7;cursor:progress}
1122
1123
  .pp-link{display:flex;gap:4px}
1124
+ /* The start page's ⚙ panel (start_settings.js): rows of segments (a label, then pill buttons with aria-pressed), the context's Apply and budget disclosure, the review row; a bottom sheet on a phone. */
1125
+ .start-settings{gap:10px}
1126
+ .start-settings .ctx-pop-head strong{font-family:inherit}
1127
+ .ss-row{display:flex;align-items:flex-start;gap:10px}
1128
+ .ss-label{flex:0 0 64px;font-size:12px;line-height:26px;color:var(--text-muted)}
1129
+ .ss-seg{flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:4px}
1130
+ .ss-seg button{height:26px;min-height:26px;padding:0 9px;border:1px solid var(--border-strong);border-radius:7px;background:transparent;color:var(--text-2);font-size:12px;line-height:24px;white-space:nowrap}
1131
+ .ss-seg button:hover{filter:none;color:var(--text);border-color:var(--accent-border)}
1132
+ .ss-seg button[aria-pressed="true"]{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
1133
+ .ss-seg button:focus-visible,.ss-link:focus-visible,.ss-back:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
1134
+ .ss-more{margin:-4px 0 0 74px}
1135
+ .ss-link{padding:2px 4px;border:0;border-radius:5px;background:transparent;color:var(--accent);font-size:12px}
1136
+ .ss-link:hover{filter:none;background:var(--accent-bg)}
1137
+ .ss-ctx-form{display:flex;flex-direction:column;gap:8px;margin:0 0 0 74px}
1138
+ .ss-anyway{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px}
1139
+ .ss-rule{width:100%;margin:0;border:0;border-top:1px solid var(--border)}
1140
+ .ss-review{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px}
1141
+ .ss-review>.ss-link:first-child{margin-left:-4px}
1142
+ .ss-review-set{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-size:12.5px}
1143
+ .ss-back{margin:0 2px 0 -6px;padding:0 6px;border:0;border-radius:5px;background:transparent;color:var(--text-muted);font-size:18px;line-height:1}
1144
+ .ss-back:hover{filter:none;color:var(--text);background:var(--bg-hover)}
1145
+ .start-settings.sheet{border-radius:14px;padding:14px 16px}
1146
+ @media(max-width:400px){.ss-row{flex-direction:column;gap:4px}.ss-label{flex:none;line-height:1.4}.ss-more,.ss-ctx-form{margin-left:0}}
1147
+ @media(pointer:coarse){.ss-seg button{height:36px;min-height:36px;line-height:34px;padding:0 12px;font-size:13px}.ss-link,.ss-back{min-height:36px}.ss-label{line-height:36px}}
1148
+ /* The start page's 🧰 list (start_toolkit.js): a row per kit (a check, its name, a muted detail), the checked one accent; a bottom sheet on a phone. */
1149
+ .start-toolkit{gap:8px}
1150
+ .start-toolkit .ctx-pop-head strong{font-family:inherit}
1151
+ .st-rows{display:flex;flex-direction:column;gap:2px;margin:0 -6px}
1152
+ .st-row{display:flex;align-items:baseline;gap:8px;width:100%;min-height:30px;padding:5px 8px 5px 6px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-2);font-size:13px;text-align:left}
1153
+ .st-row:hover{filter:none;color:var(--text);background:var(--bg-hover)}
1154
+ .st-row[aria-pressed="true"]{color:var(--accent);background:var(--accent-bg);border-color:var(--accent-border)}
1155
+ .st-row:focus-visible{outline:2px solid var(--accent);outline-offset:-1px}
1156
+ .st-check{flex:0 0 14px;text-align:center}
1157
+ .st-name{flex:none;font-weight:600}
1158
+ .st-detail{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:12px}
1159
+ .start-toolkit.sheet{border-radius:14px;padding:14px 16px}
1160
+ .start-toolkit.sheet .st-detail{white-space:normal}
1161
+ @media(pointer:coarse){.st-row{min-height:40px;align-items:center}}
1123
1162
  .rs-form{display:flex;flex-direction:column;gap:6px;margin-top:6px}
1124
1163
  .rs-form textarea{width:100%;resize:vertical;min-height:52px;font-family:ui-monospace,monospace;font-size:12px;color:var(--text);background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:6px;padding:6px 8px}
1125
1164
  .rs-form textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
@@ -1604,11 +1643,14 @@ a{color:var(--accent)}
1604
1643
  </div>
1605
1644
  <div class="actions-col">
1606
1645
  <button type="button" id="modelPick" class="model-pick" title="The model the new session starts on" aria-label="Model for the new session" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1646
+ <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1607
1647
  <button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
1608
1648
  <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1609
- <button type="button" id="reviewNew" class="llmctx-new review-new" aria-haspopup="dialog">review…</button>
1610
- <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1649
+ <button type="button" id="reviewNew" class="llmctx-new review-new" aria-haspopup="dialog" hidden></button>
1650
+ <button type="button" id="toolkitNew" class="llmctx-new toolkit-new" aria-haspopup="dialog" hidden></button>
1611
1651
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1652
+ <button type="button" id="settingsNew" class="icon-btn settings-btn" title="Session settings" aria-label="Session settings" aria-haspopup="dialog" hidden></button>
1653
+ <button type="button" id="toolkitBtn" class="icon-btn toolkit-btn" title="Toolkit" aria-label="Toolkit" aria-haspopup="dialog" hidden></button>
1612
1654
  <button type="button" id="attachBtn" class="attach-btn" title="Add images (or paste or drop them here)" aria-label="Add images" hidden></button>
1613
1655
  <input type="file" id="attachInput" accept="image/*" multiple hidden>
1614
1656
  <div class="split">
@@ -16,29 +16,22 @@
16
16
  // element as the other chips')
17
17
  // formClass, popoverClass, nowClass the form's, popover's and "Now:"
18
18
  // line's own classes
19
- // when(fresh) the form's note on when it applies (fresh: the start
20
- // page's new session, thinking only)
21
- //
22
- // The start page's new session (kind NEW_SESSION) has a thinking chip only
23
- // (thinking_chip.js keeps its choice): the form shows the model's level and
24
- // takes a choice for the next create.
19
+ // when() the form's note on when it applies
25
20
 
26
21
  import { createChipPopover } from "./chip_popover.js";
27
- import { NEW_SESSION, SESSION } from "./llm_context_chip.js";
28
22
  import { html, render } from "./vendor/lit-html.js";
29
23
 
30
24
  export const DEFAULT = "default";
31
25
 
32
- // The chip: its text, tooltip and whether the session (or the new session)
33
- // set its own; null without a summary (an older worker, or a host the
34
- // setting means nothing on).
35
- export function chipModel(cfg, summary, { kind = SESSION } = {}) {
26
+ // The chip: its text, tooltip and whether the session set its own; null
27
+ // without a summary (an older worker, or a host the setting means nothing
28
+ // on).
29
+ export function chipModel(cfg, summary) {
36
30
  if (!summary || !summary.level) return null;
37
31
  const own = !!summary.own;
38
- const what = kind === NEW_SESSION ? "the new session" : "this session";
39
- const from = own ? what : summary.source || "the model's own";
40
- const title = `${cfg.name}: ${summary.level} (${from}). Click to change it for ${what}.`;
41
- return { text: `${cfg.prefix} ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
32
+ const from = own ? "this session" : summary.source || "the model's own";
33
+ const title = `${cfg.name}: ${summary.level} (${from}). Click to change it for this session.`;
34
+ return { text: `${cfg.prefix} ${summary.level}${own ? " · session" : ""}`, title, own };
42
35
  }
43
36
 
44
37
  // The command line for a select value ("default" unsets the session's own),
@@ -57,41 +50,33 @@ export function nowText(summary) {
57
50
 
58
51
  const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
59
52
 
60
- // The popover's form (a lit-html template). For the new session (kind
61
- // NEW_SESSION), +summary+ is the model's and +choice+ the level the form
62
- // shows; +now+ is the summary its "Now:" line reads (refresh passes a
63
- // newer one, the rest stays as the form opened).
64
- export function formTemplate(cfg, summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
65
- const fresh = kind === NEW_SESSION;
66
- const chosen = (fresh ? choice : summary.own) || DEFAULT;
67
- const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
53
+ // The popover's form (a lit-html template); +now+ is the summary its "Now:"
54
+ // line reads (refresh passes a newer one, the rest stays as the form opened).
55
+ export function formTemplate(cfg, summary, { running = false, now = summary } = {}) {
56
+ const chosen = summary.own || DEFAULT;
57
+ const follow = summary.own ? "follow the model" : `follow the model (${summary.level})`;
68
58
  return html`<form class=${`${cfg.formClass} llmctx-form`}><div class="ctx-pop-head"><strong>${cfg.name}</strong
69
- ><span class="ctx-pop-kind">${fresh ? NEW_SESSION : "this session"}</span
59
+ ><span class="ctx-pop-kind">this session</span
70
60
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
71
61
  ><div class=${`ctx-pop-meta ${cfg.nowClass}`}>${nowText(now)}</div
72
62
  ><label class="llmctx-field"><span>Level</span><span class="select-wrap"><select name="level">${option(DEFAULT, follow, chosen)}${
73
63
  cfg.levels.map((level) => option(level, level, chosen))}</select></span></label
74
- ><div class="ctx-pop-meta">${cfg.when(fresh)}${running ? " A turn is running: it applies after this turn." : ""}</div
64
+ ><div class="ctx-pop-meta">${cfg.when()}${running ? " A turn is running: it applies after this turn." : ""}</div
75
65
  ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
76
66
  }
77
67
 
78
68
  // The chip's popover (chip_popover.js' shell), as createLlmContextControl's:
79
69
  // el is where the chip is drawn (clicks are delegated: #infoText,
80
70
  // info_bar.js); summary(): the open session's summary; session(): its id;
81
- // running(): a turn runs; run(line): sends the command. The start page's
82
- // (kind NEW_SESSION): summary() is the picked model's, choice() the pending
83
- // level and choose(next) takes a Set's (null for "follow the model"). id
84
- // and anchor (the chip's selector, by default its data attribute) keep it
85
- // apart from the other controls on the same element (the anchor defaults
86
- // to the config's).
71
+ // running(): a turn runs; run(line): sends the command. id and anchor (the
72
+ // chip's selector, by default the config's data attribute) keep it apart
73
+ // from the other controls on the same element.
87
74
  export function createLevelControl({ cfg, el, summary, session = () => null, running = () => false, run,
88
- kind = SESSION, choice = () => null, choose = null,
89
75
  id, anchor = cfg.anchor,
90
76
  doc = document, win = window }) {
91
- const fresh = kind === NEW_SESSION;
92
77
  let initial = null;
93
- // What the open form was drawn from: the model's or session's summary
94
- // and the form's options.
78
+ // What the open form was drawn from: the session's summary and the form's
79
+ // options.
95
80
  let shown = null;
96
81
  const draw = (now) => render(formTemplate(cfg, shown.data, { ...shown.opts, now }), shell.pop);
97
82
 
@@ -99,8 +84,8 @@ export function createLevelControl({ cfg, el, summary, session = () => null, run
99
84
  // "Now:" follows (the same template again: lit writes that line alone).
100
85
  function show(data, opening) {
101
86
  if (opening) {
102
- initial = (fresh ? choice() : data.own) || DEFAULT;
103
- shown = { data, opts: { running: running(), kind, choice: choice() } };
87
+ initial = data.own || DEFAULT;
88
+ shown = { data, opts: { running: running() } };
104
89
  }
105
90
  draw(data);
106
91
  }
@@ -109,7 +94,6 @@ export function createLevelControl({ cfg, el, summary, session = () => null, run
109
94
  const level = form.elements.level.value;
110
95
  const was = initial;
111
96
  shell.close();
112
- if (fresh) return choose(level === DEFAULT ? null : level);
113
97
  const line = commandLine(cfg, level, was);
114
98
  if (line) run(line);
115
99
  }