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
@@ -4,6 +4,7 @@ require_relative "llm_context_override"
4
4
  require_relative "llm_context_strategy"
5
5
  require_relative "llm_context_edit"
6
6
  require_relative "llm_context_stale"
7
+ require_relative "cache_confirm"
7
8
 
8
9
  module Samagotchi
9
10
  # /llm-context, the session's own LLM context values
@@ -18,7 +19,9 @@ module Samagotchi
18
19
  # system prompt (a full cache break); stale turned on stages the reads
19
20
  # already superseded as one batch under the apply rule; a layer turned
20
21
  # off sends its stubs whole again (the originals stay in the session, so
21
- # turning it back on brings them back).
22
+ # turning it back on brings them back). The two that restart the prompt
23
+ # cache (forget on or off, stubs sent whole again) ask first
24
+ # (CacheConfirm, #cache_cost); --yes (or -y) applies them.
22
25
  class LLMContextCommand
23
26
  NAME = LLMContextOverride::COMMAND
24
27
  USAGE = "usage: #{NAME} [strategy <none|stale,forget|default>] [apply <payoff|next_request|turn_end|default>] " \
@@ -33,20 +36,49 @@ module Samagotchi
33
36
  @save = save
34
37
  end
35
38
 
36
- # @param args [String] what follows /llm-context
37
- # @return [Array(String, Boolean)] the reply and whether the values changed
39
+ # @param args [String] what follows /llm-context (--yes anywhere)
40
+ # @param confirm [Boolean] ask before a change that restarts the prompt
41
+ # cache (CacheConfirm, #cache_cost); false applies it at once (chi
42
+ # send, a delegate, a start flag)
43
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
44
+ # whether the values changed, and the confirm it asks for (nothing
45
+ # changed then)
38
46
  # @raise [ArgumentError] a line that isn't one (its message and USAGE)
39
- def run(args)
40
- args = args.to_s.strip
41
- return [listing(@engine.llm_context_explained), false] if args.empty?
47
+ def run(args, confirm: true)
48
+ # Before #words: it refuses --yes, and "reset --yes" isn't RESET.
49
+ args, yes = CacheConfirm.split(args, downcase: false)
50
+ return [listing(@engine.llm_context_explained), false, nil] if args.empty?
42
51
 
43
52
  session = @engine.session or raise ArgumentError, "no session yet"
44
53
  before = @engine.llm_context_explained
45
54
  updated = args.casecmp?(RESET) ? LLMContextOverride.new : LLMContextOverride.update(@engine.llm_context_override, words(args))
55
+ ask = confirm && !yes ? ask(session, before, updated, args) : nil
56
+ return ask if ask
57
+
46
58
  @engine.llm_context_override = updated
47
59
  @save.call(session)
48
60
  after = @engine.llm_context_explained
49
- [[listing(after), *notes(session, before&.resolved, after&.resolved)].join("\n"), true]
61
+ [[listing(after), *notes(session, before&.resolved, after&.resolved)].join("\n"), true, nil]
62
+ end
63
+
64
+ # What a change from +before+ to +after+ (Resolved) costs the prompt
65
+ # cache, nil for nothing worth asking about: forget on or off changes
66
+ # the tool list and the system prompt (:full); a layer turned off while
67
+ # it has applied stubs sends them whole again (:history). Stale turned
68
+ # on only stages a batch under the apply rule (its break comes later,
69
+ # as designed), and the apply rule and budget only decide when a break
70
+ # comes: a switch to next_request with staged edits pending applies
71
+ # them at the next request, a break the old rule makes by the end of
72
+ # the next turn anyway.
73
+ # @return [Symbol, nil]
74
+ def cache_cost(session, before, after)
75
+ return nil unless before && after
76
+
77
+ was = before.active_layers
78
+ now = after.active_layers
79
+ return :full if was.include?(:forget) != now.include?(:forget)
80
+
81
+ :history if (was - now).any? { |layer| applied_count(session.messages, layer).positive? }
50
82
  end
51
83
 
52
84
  # The values, where each came from, and what the session sets itself.
@@ -66,6 +98,20 @@ module Samagotchi
66
98
 
67
99
  private
68
100
 
101
+ # The reply that asks before a costly change (CacheConfirm), nil when
102
+ # it needn't.
103
+ def ask(session, before, updated, args)
104
+ would = @engine.llm_context_explained(own: updated)
105
+ was = before&.resolved
106
+ now = would&.resolved
107
+ cost = cache_cost(session, was, now)
108
+ step = CacheConfirm.ask(line: "#{NAME} #{args}", cost: cost, changed: !cost.nil?, engine: @engine)
109
+ return nil unless step
110
+
111
+ costs = notes(session, was, now).drop(1).reject { |note| note.start_with?("stale:") }
112
+ [[listing(before), "This change restarts the prompt cache:", *costs, step.text].join("\n"), false, step]
113
+ end
114
+
69
115
  # "strategy stale stale forget apply turn_end budget 64k" as fields
70
116
  # and their words; a strategy takes the words up to the next field.
71
117
  def words(args)
@@ -55,7 +55,8 @@ module Samagotchi
55
55
  COMMAND_WORDS = { strategy: "strategy", apply: "apply", budget_tokens: "budget" }.freeze
56
56
 
57
57
  # The /llm-context line that sets +words+ (field => value as typed,
58
- # checked by .update first): what a flag on an existing session runs.
58
+ # checked by .update first): what a flag on an existing session runs,
59
+ # with --yes (typing the flag is the choice: no confirm step).
59
60
  # @return [String]
60
61
  def self.command_line(words)
61
62
  parts = words.map do |field, word|
@@ -63,7 +64,7 @@ module Samagotchi
63
64
  word = parse_strategy(word).then { |layers| layers.empty? ? "none" : layers.join(",") } if field == :strategy && !word.casecmp?(DEFAULT_WORD)
64
65
  "#{COMMAND_WORDS.fetch(field)} #{word}"
65
66
  end
66
- [COMMAND, *parts].join(" ")
67
+ [COMMAND, *parts, "--yes"].join(" ")
67
68
  end
68
69
 
69
70
  # A session file's "llm_context" (or an override already): nil when
@@ -7,6 +7,7 @@ require_relative "../commands/registry"
7
7
  require_relative "service"
8
8
  require_relative "tool_result"
9
9
  require_relative "tool_spec"
10
+ require_relative "prompt_sections"
10
11
 
11
12
  module Samagotchi
12
13
  module Plugin
@@ -45,7 +46,9 @@ module Samagotchi
45
46
  "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
46
47
  "ctx.usage" => "0.100.0", "ctx.sessions.stop" => "0.34.0", "ctx.turn_author" => "0.106.0",
47
48
  "ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
48
- "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0"
49
+ "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0",
50
+ "chi.prompt_section" => "0.132.0", "chi.prompt_changed" => "0.132.0", "ctx.cache_confirm" => "0.132.0",
51
+ "ctx.model_names" => "0.132.0"
49
52
  }.freeze
50
53
 
51
54
  # @param bundle [String]
@@ -65,6 +68,7 @@ module Samagotchi
65
68
  @services = []
66
69
  @inits = []
67
70
  @toolkits = []
71
+ @prompt_sections = []
68
72
  @peer_sources = []
69
73
  @tool_source = nil
70
74
  @committed = false
@@ -279,6 +283,42 @@ module Samagotchi
279
283
  nil
280
284
  end
281
285
 
286
+ # Text this session's system prompt carries (docs/plugins.md, Prompt
287
+ # sections): the block gets the Context and returns a String (at most
288
+ # PromptSections::MAX characters) or nil for none. It runs when the
289
+ # prompt is built (a session's first turn, after /model or a
290
+ # #prompt_changed!), never per request, on whatever thread builds:
291
+ # keep it quick (no I/O beyond reading a small file) and give the same
292
+ # text for the same inputs. A raise, a non-String or text over the
293
+ # limit keeps the section's last good text (one warn).
294
+ # @param name [String] a-z, 0-9, _ and -; unique in the bundle
295
+ def prompt_section(name, &block)
296
+ raise ArgumentError, "prompt_section #{name.inspect} needs a block" unless block
297
+
298
+ name = name.to_s
299
+ unless name.match?(PromptSections::NAME)
300
+ raise ArgumentError, "prompt section name #{name.inspect} must be a-z, 0-9, _ and - (at most 32)"
301
+ end
302
+ raise ArgumentError, "this chi has no prompt sections (plugins: false)" unless @registries.prompt_sections
303
+ if @prompt_sections.any? { |item| item[:name] == name } || @registries.prompt_sections.registered?(@bundle, name)
304
+ raise ArgumentError, "prompt section #{name} is registered twice"
305
+ end
306
+
307
+ @prompt_sections << { name: name, block: block }
308
+ nil
309
+ end
310
+
311
+ # Say this session's prompt sections would now give other text (a
312
+ # setting the plugin keeps changed): the system prompt is built again
313
+ # at its next build (the next turn's start; a running turn keeps the
314
+ # prompt it started with). That restarts the model server's prompt
315
+ # cache, so a command that changes a section asks first
316
+ # (Context#cache_confirm) and calls this only once it applies.
317
+ def prompt_changed!
318
+ @registries.prompt_changed&.call
319
+ nil
320
+ end
321
+
282
322
  # Publish tools into the session's tool index instead of declaring
283
323
  # them one by one (docs/plugins.md, "Publishing tools to the index"):
284
324
  # the model searches them with find_tools and calls one with
@@ -393,6 +433,9 @@ module Samagotchi
393
433
  end
394
434
  end
395
435
  @registries.toolkits&.concat(@toolkits)
436
+ @prompt_sections.each do |item|
437
+ @registries.prompt_sections.add(bundle: @bundle, name: item[:name], context: context, &item[:block])
438
+ end
396
439
  @registries.tool_index.add(@tool_source) if @tool_source
397
440
  @peer_sources.each { |item| @registries.peer_sources.declare(item[:name], label: item[:label], bundle: @bundle) }
398
441
  @committed = true
@@ -401,6 +444,7 @@ module Samagotchi
401
444
  # @return [Hash] how many of each it registered (for the log)
402
445
  def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
403
446
  services: @services.size, inits: @inits.size, toolkits: @toolkits.size,
447
+ prompt_sections: @prompt_sections.size,
404
448
  tool_sources: @tool_source ? 1 : 0, peer_sources: @peer_sources.size }
405
449
 
406
450
  # Each annotation's command is one #command staged.
@@ -8,6 +8,7 @@ require_relative "../session_metrics/usage"
8
8
  require_relative "../guardrails/context"
9
9
  require_relative "../hook_question"
10
10
  require_relative "../idle_client"
11
+ require_relative "../cache_confirm"
11
12
  require_relative "service"
12
13
  require_relative "side_question"
13
14
 
@@ -58,10 +59,15 @@ module Samagotchi
58
59
  # plugin handler: +handler+) until the turn has ended.
59
60
  # +cost+ returns the session's spend now (SessionMetrics::Cost),
60
61
  # +usage+ its work now (SessionMetrics::Usage).
62
+ # +cache_confirm+ takes (line, changed) and returns a
63
+ # CacheConfirm::Confirm when the change must ask first (CacheConfirm.ask
64
+ # with cost :full), else nil. +model_names+ returns the names the
65
+ # session's model is looked up under (EffectiveModel#names).
61
66
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
62
67
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
63
68
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
64
- :cancel_controller, :cost, :usage, :turn_author, keyword_init: true)
69
+ :cancel_controller, :cost, :usage, :turn_author, :cache_confirm, :model_names,
70
+ keyword_init: true)
65
71
 
66
72
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
67
73
  # sent nothing usable. The message says why, for the user.
@@ -164,6 +170,14 @@ module Samagotchi
164
170
  # current_model_only names it
165
171
  def model_key = @host.model_key&.call
166
172
 
173
+ # The names the session's model is looked up under now, as chi's own
174
+ # per-model config is (models: maps, ConfigFile.model_setting): as
175
+ # typed (an alias), the alias's target, the resolved ref ("host:id"),
176
+ # without the host, the bare id the host serves; deduped, first the
177
+ # most specific. Compare them downcased, first hit, as chi does.
178
+ # @return [Array<String>] frozen; [] when the model doesn't resolve
179
+ def model_names = Array(@host.model_names&.call).map(&:to_s).freeze
180
+
167
181
  # @return [SessionMetrics::Cost] the session's spend so far, read now:
168
182
  # #total, #reported, #estimated, #side (Float USD) and #known?
169
183
  # (docs/plugins.md); zero and known with no session
@@ -215,6 +229,25 @@ module Samagotchi
215
229
  Array(@host.load_tools&.call(names.map(&:to_s), @bundle))
216
230
  end
217
231
 
232
+ # Whether a change the plugin's command is about to make (its prompt
233
+ # section's text: chi.prompt_changed!) must ask first. nil: apply it
234
+ # now (nothing changes, no model reply yet, a sender no one answers
235
+ # for: chi send, a delegate, chi -p, the line had --yes, or not
236
+ # inside a command). Else a CacheConfirm::Confirm: change nothing and
237
+ # return [text, confirm] from the command, so the TUI prefills the
238
+ # line and the web shows Apply (it sends +line+ with --yes). The cost
239
+ # is a full re-read (the section is in the system prompt's stable
240
+ # part); the text says how big and, on api: anthropic, that preserved
241
+ # thinking is dropped.
242
+ # @param line [String] the command line that applies it, without --yes
243
+ # @param changed [Boolean] whether the section's text would change
244
+ # @return [CacheConfirm::Confirm, nil]
245
+ def cache_confirm(line, changed:)
246
+ return nil unless CacheConfirm.asking?
247
+
248
+ @host.cache_confirm&.call(line.to_s, changed ? true : false)
249
+ end
250
+
218
251
  # Whether the session is a `chi scratch` one (deleted when it ends).
219
252
  def scratch? = !!@host.scratch&.call
220
253
 
@@ -20,9 +20,12 @@ module Samagotchi
20
20
  # +init+ adds a chi.init task (Engine#add_init_task); +toolkits+ is the
21
21
  # Engine's list of the plugins' toolkits (chi.toolkit: Toolkit::Kit);
22
22
  # +tool_index+ the Engine's Tools::ToolIndex (chi.tool_source);
23
- # +peer_sources+ the Engine's PeerSources (chi.peer_source).
23
+ # +peer_sources+ the Engine's PeerSources (chi.peer_source);
24
+ # +prompt_sections+ the Engine's PromptSections (chi.prompt_section),
25
+ # +prompt_changed+ rebuilds its system prompts (chi.prompt_changed!).
24
26
  Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
25
- :toolkits, :tool_index, :peer_sources, keyword_init: true)
27
+ :toolkits, :tool_index, :peer_sources, :prompt_sections, :prompt_changed,
28
+ keyword_init: true)
26
29
 
27
30
  # Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
28
31
  # into an Engine's registries (docs/plugins.md).
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../log"
4
+ require_relative "../toolkit"
5
+
6
+ module Samagotchi
7
+ module Plugin
8
+ # The system-prompt text plugins add (chi.prompt_section): one per
9
+ # (bundle, name), each a block given the plugin's Context that returns
10
+ # a String or nil (no section). SystemPrompt runs them inside a build
11
+ # only (lazy, once per build key until a reset), never per request, on
12
+ # whatever thread builds: a block must be quick and thread-safe.
13
+ #
14
+ # Prompt-cache contract (docs/internals/prompt-caching.md): sections go
15
+ # sorted by (bundle, name), so the text is the same for the same
16
+ # inputs. A block that raises, returns something other than a String
17
+ # or nil, or text over MAX characters keeps the section's last good
18
+ # text for this session (a skip on a transient error would break the
19
+ # cache twice), with one warn; with no good text yet the section is
20
+ # left out.
21
+ class PromptSections
22
+ # As a kit prompt's (Toolkit::PROMPT_MAX): a cached prefix, not a document.
23
+ MAX = Toolkit::PROMPT_MAX
24
+ NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
25
+
26
+ Section = Data.define(:bundle, :name, :block, :context)
27
+
28
+ # A section as one build holds it.
29
+ Built = Data.define(:bundle, :name, :text) do
30
+ # "From <bundle> (<name>):\n<text>", as the prompt has it.
31
+ def prompt_text = "From #{bundle} (#{name}):\n#{text}"
32
+
33
+ # /stats' record (Engine#stats_snapshot's prompt_sections).
34
+ def to_stats = { bundle: bundle, name: name }
35
+ end
36
+
37
+ def initialize
38
+ @sections = []
39
+ @mutex = Mutex.new
40
+ # [session id, bundle, name] => the last good text (nil: none)
41
+ @last_good = {}
42
+ @warned = Set.new
43
+ end
44
+
45
+ # @raise [ArgumentError] a (bundle, name) already added
46
+ def add(bundle:, name:, context:, &block)
47
+ @mutex.synchronize do
48
+ if @sections.any? { |section| section.bundle == bundle && section.name == name }
49
+ raise ArgumentError, "prompt section #{name} is registered twice (bundle #{bundle})"
50
+ end
51
+
52
+ @sections << Section.new(bundle: bundle.to_s, name: name.to_s, block: block, context: context)
53
+ @sections.sort_by! { |section| [section.bundle, section.name] }
54
+ end
55
+ nil
56
+ end
57
+
58
+ # @return [Boolean]
59
+ def any? = @mutex.synchronize { @sections.any? }
60
+
61
+ # Whether +bundle+ already has a section +name+.
62
+ def registered?(bundle, name)
63
+ @mutex.synchronize { @sections.any? { |section| section.bundle == bundle.to_s && section.name == name.to_s } }
64
+ end
65
+
66
+ # Run every block for +session_id+ and keep what they gave.
67
+ # @param session_id [String, nil] whose last good text a failure keeps
68
+ # @return [Array<Built>] in prompt order, the sections with text
69
+ def render(session_id)
70
+ sections = @mutex.synchronize { @sections.dup }
71
+ sections.filter_map do |section|
72
+ text = evaluate(section, session_id)
73
+ Built.new(bundle: section.bundle, name: section.name, text: text) if text
74
+ end
75
+ end
76
+
77
+ # The prompt text of +built+ (#render), nil without any.
78
+ # @param built [Array<Built>]
79
+ # @return [String, nil]
80
+ def self.text(built)
81
+ built.empty? ? nil : built.map(&:prompt_text).join("\n\n")
82
+ end
83
+
84
+ private
85
+
86
+ def evaluate(section, session_id)
87
+ key = [session_id, section.bundle, section.name]
88
+ value = section.block.call(section.context)
89
+ problem = problem_of(value)
90
+ if problem
91
+ keep_last_good(section, key, problem)
92
+ else
93
+ text = value.nil? ? nil : value.strip.freeze
94
+ text = nil if text && text.empty?
95
+ @mutex.synchronize { @last_good[key] = text }
96
+ text
97
+ end
98
+ rescue StandardError => e
99
+ keep_last_good(section, key, "#{e.class}: #{e.message}")
100
+ end
101
+
102
+ def problem_of(value)
103
+ return nil if value.nil?
104
+ return "returned #{value.class}, not a String or nil" unless value.is_a?(String)
105
+
106
+ "is over #{MAX} characters" if value.strip.length > MAX
107
+ end
108
+
109
+ # One warn per section, session and problem; the last good text, or nil.
110
+ def keep_last_good(section, key, problem)
111
+ kept = @mutex.synchronize do
112
+ first = @warned.add?([*key, problem])
113
+ [@last_good[key], first]
114
+ end
115
+ text, first = kept
116
+ if first
117
+ Log.warn(:plugins, "prompt_section_failed", bundle: section.bundle, section: section.name,
118
+ error: problem.to_s[0, 200], kept: !text.nil?)
119
+ end
120
+ text
121
+ end
122
+ end
123
+ end
124
+ end
@@ -210,6 +210,7 @@ module Samagotchi
210
210
  chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
211
211
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
212
212
  chi models [--format json] [TEXT] the models --model takes, from every host
213
+ chi home [set [--ruby PATH] | doctor] the Ruby every chi launch boots
213
214
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
214
215
  chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
215
216
  chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote home].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
@@ -5,6 +5,7 @@ require_relative "model_profile"
5
5
  require_relative "served_model"
6
6
  require_relative "prompt_note"
7
7
  require_relative "turn_flow"
8
+ require_relative "cache_confirm"
8
9
  require_relative "tools/execute"
9
10
  require_relative "tools/builtins"
10
11
  require_relative "tools/context_read"
@@ -61,7 +62,7 @@ module Samagotchi
61
62
 
62
63
  # Commands that may ask before a change that restarts the prompt cache
63
64
  # (CacheConfirm): #run passes them its confirm flag.
64
- CONFIRMING = %i[thinking reasoning_history toolkit].freeze
65
+ CONFIRMING = %i[model llm_context thinking reasoning_history toolkit].freeze
65
66
 
66
67
  # @!attribute status [Symbol] :ok, or :error when the line was refused
67
68
  # @!attribute output [String, nil] what to tell the user
@@ -302,7 +303,7 @@ module Samagotchi
302
303
  text = line.to_s.strip
303
304
  entry = @registry.lookup(text)
304
305
  return nil unless entry&.handler
305
- return run_bundle_command(entry, text) unless entry.source == "core"
306
+ return run_bundle_command(entry, text, confirm: confirm) unless entry.source == "core"
306
307
 
307
308
  # Only commands that may ask take the flag.
308
309
  return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
@@ -355,16 +356,32 @@ module Samagotchi
355
356
  # The resolved ref (what the session stores and --alias/--default write).
356
357
  def model_name = @engine.effective_model_ref
357
358
 
358
- # A bundle plugin's command: its handler gets the text after the name;
359
- # what it returns is the output (nil: nothing to show), and a raise is
360
- # an error result.
361
- def run_bundle_command(entry, text)
362
- output = entry.handler.call(text.delete_prefix(entry.name).strip)
363
- reply(output.nil? ? nil : output.to_s)
359
+ # A bundle plugin's command: its handler gets the text after the name
360
+ # (--yes kept); what it returns is the output (nil: nothing to show),
361
+ # or [output, CacheConfirm::Confirm or nil] for a change that asks
362
+ # first (ctx.cache_confirm), and a raise is an error result. The
363
+ # sender's +confirm+ (false once the line has --yes) reaches
364
+ # ctx.cache_confirm through the thread (CacheConfirm.asking).
365
+ def run_bundle_command(entry, text, confirm: false)
366
+ args = text.delete_prefix(entry.name).strip
367
+ _, yes = CacheConfirm.split(args)
368
+ output, ask = bundle_reply(CacheConfirm.asking(confirm && !yes) { entry.handler.call(args) })
369
+ reply(output, confirm: ask)
364
370
  rescue StandardError => e
365
371
  reply("#{entry.name}: #{e.class}: #{e.message}", status: :error)
366
372
  end
367
373
 
374
+ # A plugin command's return value as [output, confirm]: a String (or
375
+ # anything, shown as text) or nil, or [text, Confirm/nil].
376
+ def bundle_reply(value)
377
+ if value.is_a?(Array) && value.size == 2 && (value[1].nil? || value[1].is_a?(CacheConfirm::Confirm))
378
+ text, ask = value
379
+ return [text.nil? ? nil : text.to_s, ask]
380
+ end
381
+
382
+ [value.nil? ? nil : value.to_s, nil]
383
+ end
384
+
368
385
  def help_group(entry)
369
386
  return 2 if entry.local
370
387
 
@@ -583,13 +600,13 @@ module Samagotchi
583
600
 
584
601
  # /llm-context (LLMContextCommand); :llm_context in changed when it set
585
602
  # the session's values.
586
- def llm_context(text)
587
- output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
588
- .run(text.delete_prefix(LLM_CONTEXT_COMMAND))
603
+ def llm_context(text, confirm: true)
604
+ output, changed, ask = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
605
+ .run(text.delete_prefix(LLM_CONTEXT_COMMAND), confirm: confirm)
589
606
  # What the model has of each item is the next turn's strategy's: the
590
607
  # working set goes out again (sent only when it differs).
591
608
  @engine.announce_working_set if changed
592
- reply(output, changed: changed ? [:llm_context] : [])
609
+ reply(output, changed: changed ? [:llm_context] : [], confirm: ask)
593
610
  rescue ArgumentError => e
594
611
  reply(e.message, status: :error)
595
612
  end
@@ -672,16 +689,23 @@ module Samagotchi
672
689
  @save.call(session)
673
690
  end
674
691
 
675
- def model(text)
676
- output, switched = model_command(text)
677
- reply(output, changed: switched ? [:model] : [])
692
+ # /model; a switch to another model asks first (CacheConfirm, cost
693
+ # :full: the new model has no cache of the conversation), --yes applies.
694
+ def model(text, confirm: true)
695
+ suffix, yes = CacheConfirm.split(text.delete_prefix(MODEL_COMMAND), downcase: false)
696
+ output, switched, ask = model_command("#{MODEL_COMMAND} #{suffix}", confirm: confirm && !yes)
697
+ reply(output, changed: switched ? [:model] : [], confirm: ask)
678
698
  rescue ModelProfile::MissingModel => e
679
699
  # A model qualified with an unknown host: nothing switched.
680
700
  reply(e.message, status: :error)
681
701
  end
682
702
 
683
- # @return [Array(String, Boolean)] the message, and whether the model changed
684
- def model_command(input)
703
+ # @param input [String] the /model line, --yes taken out
704
+ # @param confirm [Boolean] a switch to another model asks first
705
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the message,
706
+ # whether the model changed, and the confirm it asks for (nothing
707
+ # changed then)
708
+ def model_command(input, confirm: false)
685
709
  suffix = input.delete_prefix(MODEL_COMMAND).strip
686
710
  if suffix.empty?
687
711
  return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
@@ -735,6 +759,9 @@ module Samagotchi
735
759
  invalid = ConfigFile.model_alias_error(alias_name, arg)
736
760
  return ["invalid alias: #{invalid}", false] if invalid
737
761
 
762
+ ask = confirm && model_ask(arg, input)
763
+ return ask if ask
764
+
738
765
  switch_model(arg, persist_default: persist_default)
739
766
 
740
767
  begin
@@ -756,10 +783,16 @@ module Samagotchi
756
783
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
757
784
  raise ModelProfile::MissingModel, "no default model is configured (set default.model in config.yml, or /model <name> --default)" if @default_model.nil?
758
785
 
786
+ ask = confirm && model_ask(@default_model, input)
787
+ return ask if ask
788
+
759
789
  switch_model(@default_model)
760
790
  return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
761
791
  end
762
792
 
793
+ ask = confirm && model_ask(arg, input)
794
+ return ask if ask
795
+
763
796
  switch_model(arg, persist_default: persist_default)
764
797
  if persist_default
765
798
  ["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
@@ -769,6 +802,20 @@ module Samagotchi
769
802
  end
770
803
  end
771
804
 
805
+ # The reply that asks before switching to +name+ (CacheConfirm), nil
806
+ # when it needn't: the name is checked first (a typo errors at once,
807
+ # MissingModel), and its resolved ref against the current one (the same
808
+ # model costs nothing; any other restarts the whole cache).
809
+ # @param line [String] the /model line as typed (its flags too), --yes taken out
810
+ def model_ask(name, line)
811
+ ref = @engine.model_switch_ref(name)
812
+ step = CacheConfirm.ask(line: line, cost: :full, changed: ref != model_name, engine: @engine)
813
+ return nil unless step
814
+
815
+ ["runtime model: #{model_name}\nSwitching to #{ref} restarts the prompt cache: the new model has no cache of " \
816
+ "this conversation.\n#{step.text}", false, step]
817
+ end
818
+
772
819
  # Engine owns the switch (alias resolution, profile, kernel, client and
773
820
  # the optional default persist); the session keeps the new model.
774
821
  def switch_model(name, persist_default: false)
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
+ require_relative "chi_home"
8
9
  require_relative "child_env"
9
10
  require_relative "client_id"
10
11
  require_relative "analytics_file"
@@ -1252,16 +1253,50 @@ module Samagotchi
1252
1253
  # as it is read, so an rspec run by the worker's execute never forks.
1253
1254
  WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
1254
1255
 
1255
- def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
1256
+ # The boot string's rescue under a chi home: one line in the spawner's
1257
+ # log file (SAMAGOTCHI_LOG_FILE, set by spawn_options), LogLine's shape,
1258
+ # then exit. Single-quoted: the #{} run in the worker. SID is replaced.
1259
+ # rubocop:disable-next Lint/InterpolationCheck -- the worker interpolates these, not this process
1260
+ HOME_BOOT_FAILED = 'rescue ScriptError, StandardError => e; f = ENV["SAMAGOTCHI_LOG_FILE"].to_s; begin; ' \
1261
+ 'File.open(f, "a") { |io| io.puts(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") + ' \
1262
+ '" ERROR worker pid=#{Process.pid} sid=SID boot_failed ruby=#{RbConfig.ruby.inspect} error=" + ' \
1263
+ "e.message.lines.first.to_s.strip.inspect) } unless f.empty?; rescue SystemCallError; end; exit 1"
1264
+
1265
+ def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
1266
+ home: ChiHome.current(gem_spec: gem_spec))
1256
1267
  boot = "require 'samagotchi/session_manager'; " \
1257
1268
  "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
1258
1269
  return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
1270
+ return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
1259
1271
 
1260
1272
  activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
1261
1273
  "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
1262
1274
  [RbConfig.ruby, "-e", "#{activate}; #{boot}"]
1263
1275
  end
1264
1276
 
1277
+ # With a chi home (ChiHome.current, docs/architecture.md "How chi
1278
+ # launches itself"): the home's ruby, `Gem.paths` set to its gems in the
1279
+ # boot string (no GEM_* in the worker's env), the newest chi there, else
1280
+ # the fallback pin (ChiHome.fallback_version). A home that still can't
1281
+ # load chi (a broken install) logs HOME_BOOT_FAILED and exits: never a
1282
+ # silent spawn timeout.
1283
+ private_class_method def self.home_worker_command(session_id, state_dir:, gem_spec:, home:)
1284
+ newest = home.newest_version
1285
+ if newest && newest < Gem::Version.new(gem_spec.version.to_s)
1286
+ ChiHome.warn_once(:older_than_spawner, home: newest.to_s, spawner: gem_spec.version.to_s)
1287
+ end
1288
+ fallback = ChiHome.fallback_version(home, gem_spec)
1289
+ activate = if fallback
1290
+ "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
1291
+ "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{fallback}'; end"
1292
+ else
1293
+ "gem 'samagotchi', '>= 0.a'"
1294
+ end
1295
+ home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
1296
+ "#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
1297
+ "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
1298
+ end
1299
+
1265
1300
  private_class_method def self.spawn_worker_for_session(session, state_dir:)
1266
1301
  session_dir = Session.session_dir(session.id, state_dir: state_dir)
1267
1302
  FileUtils.mkdir_p(session_dir)
@@ -61,9 +61,12 @@ module Samagotchi
61
61
 
62
62
  # #turn_llm_context with where each value came from
63
63
  # (LLMContextStrategy.explain), nil when it can't be resolved.
64
+ # @param own [LLMContextOverride, nil] the session's own values to
65
+ # resolve with (the ones a change would set: /llm-context's confirm)
64
66
  # @return [LLMContextStrategy::Explained, nil]
65
- def llm_context_explained
66
- model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
67
+ def llm_context_explained(own: :session)
68
+ args = own == :session ? llm_context_session_args : own&.resolve_args || {}
69
+ model_setting(nil) { |model| model.llm_context_explained(args) }
67
70
  end
68
71
 
69
72
  # #llm_context_explained's summary (Explained#summary), nil when it