samagotchi 0.67.0 → 0.68.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -1
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cache_confirm"
4
+
5
+ module Samagotchi
6
+ # A command for a session's own level of a setting (/thinking,
7
+ # /reasoning-history): with no argument, the level the next turn runs at
8
+ # and where it came from; with a level, sets the session's own (the
9
+ # setting's unset word unsets it). A change takes effect at the next
10
+ # turn's start (the REPL and the worker run it between turns), and the
11
+ # reply says what it costs the prompt cache. A costly change asks first
12
+ # (CacheConfirm); --yes (or -y) applies it.
13
+ #
14
+ # A subclass gives its Spec (#spec): what differs between the settings.
15
+ class LevelSettingCommand
16
+ # name: the command; label: the reply's ("thinking: low (session)");
17
+ # usage: the usage line an unknown word gets;
18
+ # parse: word → [known?, the session's own level (nil unsets)];
19
+ # own: engine → the session's own level now;
20
+ # explained: (engine, own) → SettingExplained, nil when it can't be resolved;
21
+ # set: (engine, own) → sets the session's own;
22
+ # cache_cost: (engine, from_level, to_level) → a cost_notes key, nil for none;
23
+ # cost_notes: {cost => the reply's line};
24
+ # follows: what the session follows with no own level;
25
+ # extra_line: engine → one more listing line, nil for none.
26
+ Spec = Data.define(:name, :label, :usage, :parse, :own, :explained, :set, :cache_cost, :cost_notes, :follows,
27
+ :extra_line)
28
+
29
+ # @param engine [Engine] the session's (#session, #history? and the spec's readers)
30
+ # @param save [#call] saves the session after a change
31
+ def initialize(engine:, save:)
32
+ @engine = engine
33
+ @save = save
34
+ end
35
+
36
+ # @param args [String] what follows the command (--yes anywhere)
37
+ # @param confirm [Boolean] ask before a costly change (CacheConfirm);
38
+ # false applies it at once (chi send, a delegate, a start flag)
39
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
40
+ # whether the level changed, and the confirm it asks for (nothing
41
+ # changed then)
42
+ # @raise [ArgumentError] a word that isn't a level (with the usage)
43
+ def run(args, confirm: true)
44
+ word, yes = CacheConfirm.split(args)
45
+ return [listing(explained), false, nil] if word.empty?
46
+
47
+ known, own = spec.parse.call(word)
48
+ raise ArgumentError, "unknown #{spec.label} level #{word}\n#{spec.usage}" unless known
49
+
50
+ session = @engine.session or raise ArgumentError, "no session yet"
51
+ before = explained
52
+ ask = confirm && !yes ? ask(before, own, word) : nil
53
+ return ask if ask
54
+
55
+ spec.set.call(@engine, own)
56
+ @save.call(session)
57
+ after = explained
58
+ [[listing(after), *notes(before, after)].join("\n"), true, nil]
59
+ end
60
+
61
+ # The level, where it came from, and what the session sets itself.
62
+ def listing(explained)
63
+ return "#{spec.label}: can't be resolved for this model" unless explained
64
+
65
+ lines = ["#{spec.label}: #{explained.label}"]
66
+ lines << " this session's own: none (follows #{spec.follows})" unless explained.own
67
+ extra = spec.extra_line&.call(@engine)
68
+ lines << extra if extra
69
+ lines.join("\n")
70
+ end
71
+
72
+ private
73
+
74
+ def spec = self.class::SPEC
75
+
76
+ def explained(own: spec.own.call(@engine)) = spec.explained.call(@engine, own)
77
+
78
+ # The reply that asks before a costly change, nil when it needn't.
79
+ def ask(before, own, word)
80
+ would = explained(own: own)
81
+ return nil unless before && would
82
+
83
+ cost = spec.cache_cost.call(@engine, before.level, would.level)
84
+ return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
85
+
86
+ step = CacheConfirm.confirm(spec.name, word)
87
+ [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{spec.cost_notes[cost]}", step.text].join("\n"),
88
+ false, step]
89
+ end
90
+
91
+ def notes(before, after)
92
+ return [] unless before && after
93
+ return ["(no change to what the next turn runs under)"] if before.level == after.level
94
+
95
+ cost = spec.cache_cost.call(@engine, before.level, after.level)
96
+ ["From the next turn's start (a running turn keeps its own).", spec.cost_notes[cost]].compact
97
+ end
98
+ end
99
+ end
@@ -211,17 +211,12 @@ module Samagotchi
211
211
  def for_session(session, registry: nil)
212
212
  return nil if session.nil?
213
213
 
214
- require_relative "config"
215
- require_relative "model_profile"
216
- registry ||= begin
217
- require_relative "host_registry"
218
- HostRegistry.new
219
- end
220
- name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
221
- target = registry.resolve(name)
222
- names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
214
+ # Here, not at the top: config.rb requires this file before ConfigFile
215
+ # exists, and effective_model.rb requires config.rb (via host_registry.rb).
216
+ require_relative "effective_model"
217
+ registry ||= HostRegistry.new
223
218
  own = session.llm_context
224
- explain(target, names: names, **(own&.resolve_args || {})).summary(own)
219
+ EffectiveModel.of_session(session, registry: registry).llm_context_explained(own&.resolve_args || {})&.summary(own)
225
220
  rescue StandardError
226
221
  nil
227
222
  end
@@ -1,20 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "cache_confirm"
3
+ require_relative "level_setting_command"
4
4
  require_relative "reasoning_history"
5
5
 
6
6
  module Samagotchi
7
7
  # /reasoning-history, the session's own reasoning_history level
8
- # (Session#reasoning_history): with no argument, the level the next turn
9
- # runs under and where it came from; `/reasoning-history drop|turn|all`
10
- # sets the session's own (an explicit turn too), `/reasoning-history
11
- # default` unsets it (the session follows SAMAGOTCHI_REASONING_HISTORY,
12
- # the model, its host, then turn). A change takes effect at the next
13
- # turn's start; a native host ignores the level (its loop keeps the
14
- # running turn's reasoning), but the session keeps it for a chat model.
15
- # A change that rewrites the earlier replies asks first (CacheConfirm);
16
- # --yes (or -y) applies it.
17
- class ReasoningHistoryCommand
8
+ # (Session#reasoning_history): `/reasoning-history drop|turn|all` sets it
9
+ # (an explicit turn too), `/reasoning-history default` unsets it (the
10
+ # session follows SAMAGOTCHI_REASONING_HISTORY, the model, its host, then
11
+ # turn). Resolved on any host: a native one ignores the level (its loop
12
+ # keeps the running turn's reasoning), but the session keeps it for a
13
+ # chat model. A change that rewrites the earlier replies asks first. See
14
+ # LevelSettingCommand.
15
+ class ReasoningHistoryCommand < LevelSettingCommand
18
16
  NAME = "/reasoning-history"
19
17
  USAGE = "usage: #{NAME} [#{(ReasoningHistory::LEVELS + [ReasoningHistory::UNSET]).join("|")}]".freeze
20
18
  NATIVE_NOTE = " (ignored on this host: the native loop keeps the running turn's reasoning)"
@@ -24,84 +22,25 @@ module Samagotchi
24
22
  "from the first earlier thought."
25
23
  }.freeze
26
24
 
27
- # @param engine [Engine] #reasoning_history_explained,
28
- # #reasoning_history_override(=), #session, #chat_model?,
29
- # #reasoning_history_cache_cost, #history?
30
- # @param save [#call] saves the session after a change
31
- def initialize(engine:, save:)
32
- @engine = engine
33
- @save = save
34
- end
35
-
36
- # @param args [String] what follows /reasoning-history (--yes anywhere)
37
- # @param confirm [Boolean] ask before a costly change (CacheConfirm);
38
- # false applies it at once
39
- # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
40
- # whether the level changed, and the confirm it asks for (nothing
41
- # changed then)
42
- # @raise [ArgumentError] a word that isn't a level (with USAGE)
43
- def run(args, confirm: true)
44
- word, yes = CacheConfirm.split(args)
45
- return [listing(explained), false, nil] if word.empty?
46
-
47
- level = ReasoningHistory.session_level(word)
48
- raise ArgumentError, "unknown reasoning history level #{word}\n#{USAGE}" unless level || word == ReasoningHistory::UNSET
49
-
50
- session = @engine.session or raise ArgumentError, "no session yet"
51
- before = explained
52
- ask = confirm && !yes ? ask(before, level, word) : nil
53
- return ask if ask
54
-
55
- @engine.reasoning_history_override = level
56
- @save.call(session)
57
- after = explained
58
- [[listing(after), *notes(before, after)].join("\n"), true, nil]
59
- end
60
-
61
- # The level, where it came from, and what the session sets itself.
62
- def listing(explained)
63
- return "reasoning history: can't be resolved for this model" unless explained
64
-
65
- lines = ["reasoning history: #{explained.label}"]
66
- unless explained.own
67
- lines << " this session's own: none (follows #{ReasoningHistory::ENV_NAME}, the model, its host, then turn)"
68
- end
69
- lines << NATIVE_NOTE unless chat?
70
- lines.join("\n")
71
- end
72
-
73
- private
74
-
75
- # Resolved on any host: a native one ignores it, but /reasoning-history
76
- # still shows what a chat model would get.
77
- def explained(own: @engine.reasoning_history_override)
78
- @engine.reasoning_history_explained(native: true, own: own)
79
- end
80
-
81
- # The reply that asks before a costly change, nil when it needn't.
82
- def ask(before, own, word)
83
- would = explained(own: own)
84
- return nil unless before && would
85
-
86
- cost = @engine.reasoning_history_cache_cost(before.level, would.level)
87
- return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
88
-
89
- step = CacheConfirm.confirm(NAME, word)
90
- [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
91
- end
92
-
93
- def chat?
94
- @engine.chat_model?
95
- rescue StandardError
96
- false
97
- end
98
-
99
- def notes(before, after)
100
- return [] unless before && after
101
- return ["(no change to what the next turn runs under)"] if before.level == after.level
102
-
103
- cost = @engine.reasoning_history_cache_cost(before.level, after.level)
104
- ["From the next turn's start (a running turn keeps its own).", COST_NOTES[cost]].compact
105
- end
25
+ SPEC = Spec.new(
26
+ name: NAME, label: "reasoning history", usage: USAGE, cost_notes: COST_NOTES,
27
+ parse: lambda { |word|
28
+ level = ReasoningHistory.session_level(word)
29
+ [!level.nil? || word == ReasoningHistory::UNSET, level]
30
+ },
31
+ own: lambda(&:reasoning_history_override),
32
+ explained: ->(engine, own) { engine.reasoning_history_explained(native: true, own: own) },
33
+ set: ->(engine, own) { engine.reasoning_history_override = own },
34
+ cache_cost: ->(engine, from, to) { engine.reasoning_history_cache_cost(from, to) },
35
+ follows: "#{ReasoningHistory::ENV_NAME}, the model, its host, then turn",
36
+ extra_line: lambda { |engine|
37
+ chat = begin
38
+ engine.chat_model?
39
+ rescue StandardError
40
+ false
41
+ end
42
+ chat ? nil : NATIVE_NOTE
43
+ }
44
+ )
106
45
  end
107
46
  end
@@ -259,6 +259,8 @@ module Samagotchi
259
259
  self.interrupted_turn = interrupted_turn
260
260
  @turn_checkpoint = nil
261
261
  @archived = false
262
+ # #save's lock: one snapshot-and-write at a time (see #save).
263
+ @save_lock = Mutex.new
262
264
  end
263
265
 
264
266
  # A `delegate` child: its turns report to its parent (ChildReports). A
@@ -513,18 +515,30 @@ module Samagotchi
513
515
  end
514
516
 
515
517
  # Persist the session atomically. Updates +updated_at+ in place.
518
+ #
519
+ # One save at a time per Session: a worker saves its session from the
520
+ # loop/turn thread (TurnCheckpoint, Engine's turn end, WorkerInbound),
521
+ # from Bridge threads (QuestionDesk, commands run mid-turn) and from a
522
+ # signal's thread. AtomicFile keeps each write whole, but two
523
+ # unserialized saves could rename an older snapshot over a newer one (a
524
+ # lost update); under the lock the last rename is the latest snapshot.
525
+ # Never call it from a trap handler (Mutex#lock raises ThreadError
526
+ # there; WorkerSignals saves on a thread of its own) or from inside a
527
+ # save (the lock isn't re-entrant).
516
528
  def save(state_dir: self.class.default_state_dir)
517
- @updated_at = Time.now.iso8601(3)
518
- # Persist with current metadata version so new flag is written
519
- @metadata_version = METADATA_VERSION
520
- FileUtils.mkdir_p(state_dir)
529
+ @save_lock.synchronize do
530
+ @updated_at = Time.now.iso8601(3)
531
+ # Persist with current metadata version so new flag is written
532
+ @metadata_version = METADATA_VERSION
533
+ FileUtils.mkdir_p(state_dir)
521
534
 
522
- path = self.class.session_file(@id, state_dir: state_dir)
535
+ path = self.class.session_file(@id, state_dir: state_dir)
523
536
 
524
- # Auto-compute first_preview if not yet cached and messages contain a user entry.
525
- compute_first_preview!
537
+ # Auto-compute first_preview if not yet cached and messages contain a user entry.
538
+ compute_first_preview!
526
539
 
527
- AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
540
+ AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
541
+ end
528
542
  self
529
543
  end
530
544
 
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effective_model"
4
+
5
+ module Samagotchi
6
+ # One per-session setting (the session's own value, before its model's):
7
+ # what the UIs show as a chip and the commands set. +key+ is the Session
8
+ # attribute and the chip's key everywhere (the session state, /stats, a
9
+ # :command_ran, the web's session JSON); +changed_by+ the commands'
10
+ # `changed` entries that move the chip; +snapshot_fallback+ how the web
11
+ # treats a worker snapshot's value: :or (a nil value falls back to the
12
+ # session file's) or :key (a key present stands, nil included: a worker
13
+ # says nil on a native host, which ignores reasoning_history; an older
14
+ # worker has no key). +chip+ the chip from an EffectiveModel and the
15
+ # session's own value; nil for the toolkit, which resolves against the
16
+ # worker's kits (Engine#toolkit_summary), not the model. +live+ the
17
+ # chip as an Engine's public readers give it (a :command_ran's).
18
+ SessionSetting = Data.define(:key, :changed_by, :snapshot_fallback, :chip, :live) do
19
+ # The session's own value, nil without a session or one.
20
+ def own(session) = session&.public_send(key)
21
+
22
+ # Sets the session's own value (nil unsets it); the caller saves.
23
+ def set(session, value) = session.public_send(:"#{key}=", value)
24
+
25
+ # Whether a command that changed +changed+ moves this chip.
26
+ def changed_by?(changed) = changed.intersect?(changed_by)
27
+
28
+ # Whether the chip comes from the model (not the toolkit).
29
+ def model? = !chip.nil?
30
+
31
+ # The chip for +session+ on +model+ (EffectiveModel, nil when it
32
+ # doesn't resolve: nil then).
33
+ def chip_for(model, session) = model && chip.call(model, own(session))
34
+
35
+ # The chip as +engine+ (a worker's) gives it now.
36
+ def summary(engine) = live.call(engine)
37
+
38
+ # The chip for a saved session no worker runs (Web::App, the session
39
+ # lists): resolved from its file, config and the registry (no network);
40
+ # nil when it can't be.
41
+ def saved_summary(session, registry:)
42
+ return saved_toolkit(session.toolkit) unless model?
43
+
44
+ chip_for(EffectiveModel.of_session(session, registry: registry), session)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ # The web's chip: a worker's snapshot (string keys) per
50
+ # +snapshot_fallback+, else the block's (the saved one).
51
+ def snapshot_value(snapshot)
52
+ name = key.to_s
53
+ return snapshot[name] if snapshot_fallback == :key && snapshot&.key?(name)
54
+ return snapshot[name] if snapshot_fallback == :or && snapshot && snapshot[name]
55
+
56
+ yield
57
+ end
58
+
59
+ private
60
+
61
+ # A toolkit as its file names it ({name:, label:}), nil on full:
62
+ # without a worker nothing resolves it (a bundle's kits load there).
63
+ def saved_toolkit(toolkit)
64
+ toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
65
+ end
66
+ end
67
+
68
+ class SessionSetting
69
+ LLM_CONTEXT = new(key: :llm_context, changed_by: %i[llm_context model], snapshot_fallback: :or,
70
+ chip: ->(model, own) { model.llm_context_explained(own&.resolve_args || {})&.summary(own) },
71
+ live: lambda(&:llm_context_summary))
72
+ THINKING = new(key: :thinking, changed_by: %i[thinking model], snapshot_fallback: :or,
73
+ chip: ->(model, own) { model.thinking_explained(own: own)&.summary },
74
+ live: ->(engine) { engine.thinking_explained&.summary })
75
+ # nil on a native host (its loop ignores the level).
76
+ REASONING_HISTORY = new(key: :reasoning_history, changed_by: %i[reasoning_history model], snapshot_fallback: :key,
77
+ chip: ->(model, own) { model.reasoning_history_explained(own: own)&.summary },
78
+ live: ->(engine) { engine.reasoning_history_explained&.summary })
79
+ TOOLKIT = new(key: :toolkit, changed_by: %i[toolkit], snapshot_fallback: :key, chip: nil, live: lambda(&:toolkit_summary))
80
+
81
+ # In the order the session state, a :command_ran and the web name them.
82
+ ALL = [LLM_CONTEXT, THINKING, REASONING_HISTORY, TOOLKIT].freeze
83
+ # /stats's (no toolkit: its JSON never had one).
84
+ STATS = [LLM_CONTEXT, THINKING, REASONING_HISTORY].freeze
85
+ end
86
+ end
@@ -1,18 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "cache_confirm"
3
+ require_relative "level_setting_command"
4
4
  require_relative "thinking"
5
5
 
6
6
  module Samagotchi
7
- # /thinking, the session's own thinking level (Session#thinking): with no
8
- # argument, the level the next turn runs at and where it came from;
9
- # `/thinking off|low|medium|high` sets the session's own, `/thinking
10
- # default` unsets it (the session follows the process default, the
11
- # model, its host, then thinking.level). A change takes effect at the
12
- # next turn's start (the REPL and the worker run it between turns), and
13
- # the reply says what it costs the prompt cache (Thinking.cache_cost).
14
- # A costly change asks first (CacheConfirm); --yes (or -y) applies it.
15
- class ThinkingCommand
7
+ # /thinking, the session's own thinking level (Session#thinking):
8
+ # `/thinking off|low|medium|high` sets it, `/thinking default` unsets it
9
+ # (the session follows the process default, the model, its host, then
10
+ # thinking.level). What a change costs the prompt cache:
11
+ # Thinking.cache_cost. See LevelSettingCommand.
12
+ class ThinkingCommand < LevelSettingCommand
16
13
  NAME = "/thinking"
17
14
  SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
18
15
  USAGE = "usage: #{NAME} [#{Thinking::LEVELS.join("|")}]".freeze
@@ -24,71 +21,22 @@ module Samagotchi
24
21
  tail: "Only the prompt's tail changes: the cache keeps the rest."
25
22
  }.freeze
26
23
 
27
- # @param engine [Engine] #thinking_explained(own:), #thinking_override(=),
28
- # #session, #thinking_cache_cost, #history?
29
- # @param save [#call] saves the session after a change
30
- def initialize(engine:, save:)
31
- @engine = engine
32
- @save = save
33
- end
34
-
35
- # @param args [String] what follows /thinking (--yes anywhere)
36
- # @param confirm [Boolean] ask before a costly change (CacheConfirm);
37
- # false applies it at once (chi send, a delegate, a start flag)
38
- # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
39
- # whether the level changed, and the confirm it asks for (nothing
40
- # changed then)
41
- # @raise [ArgumentError] a word that isn't a level (with USAGE)
42
- def run(args, confirm: true)
43
- word, yes = CacheConfirm.split(args)
44
- return [listing(@engine.thinking_explained), false, nil] if word.empty?
45
-
46
- level = word.to_sym
47
- raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
48
-
49
- session = @engine.session or raise ArgumentError, "no session yet"
50
- own = SETTABLE.include?(level) ? level : nil
51
- before = @engine.thinking_explained
52
- ask = confirm && !yes ? ask(before, own, word) : nil
53
- return ask if ask
54
-
55
- @engine.thinking_override = own
56
- @save.call(session)
57
- after = @engine.thinking_explained
58
- [[listing(after), *notes(before, after)].join("\n"), true, nil]
59
- end
60
-
61
- # The level, where it came from, and what the session sets itself.
62
- def listing(explained)
63
- return "thinking: can't be resolved for this model" unless explained
64
-
65
- lines = ["thinking: #{explained.label}"]
66
- unless explained.own
67
- lines << " this session's own: none (follows chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, " \
68
- "its host, then thinking.level)"
69
- end
70
- lines.join("\n")
71
- end
72
-
73
- private
74
-
75
- # The reply that asks before a costly change, nil when it needn't.
76
- def ask(before, own, word)
77
- would = @engine.thinking_explained(own: own)
78
- return nil unless before && would
79
-
80
- cost = @engine.thinking_cache_cost
81
- return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
82
-
83
- step = CacheConfirm.confirm(NAME, word)
84
- [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
85
- end
86
-
87
- def notes(before, after)
88
- return [] unless before && after
89
- return ["(no change to what the next turn runs under)"] if before.level == after.level
90
-
91
- ["From the next turn's start (a running turn keeps its own).", COST_NOTES[@engine.thinking_cache_cost]].compact
92
- end
24
+ SPEC = Spec.new(
25
+ name: NAME, label: "thinking", usage: USAGE, cost_notes: COST_NOTES,
26
+ parse: lambda { |word|
27
+ level = word.to_sym
28
+ if Thinking::LEVELS.include?(level)
29
+ [true, SETTABLE.include?(level) ? level : nil]
30
+ else
31
+ [false, nil]
32
+ end
33
+ },
34
+ own: lambda(&:thinking_override),
35
+ explained: ->(engine, own) { engine.thinking_explained(own: own) },
36
+ set: ->(engine, own) { engine.thinking_override = own },
37
+ cache_cost: ->(engine, _from, _to) { engine.thinking_cache_cost },
38
+ follows: "chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, its host, then thinking.level",
39
+ extra_line: nil
40
+ )
93
41
  end
94
42
  end