samagotchi 0.100.1 → 0.102.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +14 -10
  4. data/docs/architecture.md +10 -2
  5. data/docs/cli.md +100 -5
  6. data/docs/configuration.md +26 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +205 -0
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +8 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +92 -53
  24. data/lib/samagotchi/engine.rb +153 -4
  25. data/lib/samagotchi/host_keys.rb +35 -18
  26. data/lib/samagotchi/idle_scheduler.rb +16 -1
  27. data/lib/samagotchi/llm_context_edit.rb +32 -10
  28. data/lib/samagotchi/llm_context_forget.rb +12 -3
  29. data/lib/samagotchi/llm_context_view.rb +19 -7
  30. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  31. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  32. data/lib/samagotchi/message_parts.rb +8 -3
  33. data/lib/samagotchi/plugin/context.rb +2 -2
  34. data/lib/samagotchi/prompt.rb +1 -1
  35. data/lib/samagotchi/recap_command.rb +68 -0
  36. data/lib/samagotchi/recap_setup.rb +27 -14
  37. data/lib/samagotchi/send_command.rb +14 -11
  38. data/lib/samagotchi/session.rb +19 -12
  39. data/lib/samagotchi/session_commands.rb +44 -2
  40. data/lib/samagotchi/session_config.rb +411 -0
  41. data/lib/samagotchi/session_setup.rb +9 -9
  42. data/lib/samagotchi/sessions_command.rb +6 -2
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  45. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  46. data/lib/samagotchi/terminal_ui.rb +8 -6
  47. data/lib/samagotchi/testing/bundle.rb +3 -2
  48. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  49. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  50. data/lib/samagotchi/tool_response.rb +10 -6
  51. data/lib/samagotchi/tool_runner.rb +25 -6
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +76 -11
  54. data/lib/samagotchi/web/public/app.js +35 -3
  55. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  56. data/lib/samagotchi/web/public/commands.js +8 -2
  57. data/lib/samagotchi/web/public/data.js +7 -0
  58. data/lib/samagotchi/web/public/index.html +145 -1
  59. data/lib/samagotchi/web/public/info_bar.js +16 -4
  60. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  61. data/lib/samagotchi/web/public/refs.js +10 -1
  62. data/lib/samagotchi/web/public/stage_model.js +6 -5
  63. data/lib/samagotchi/web/public/stage_view.js +34 -2
  64. data/lib/samagotchi/web/public/stream_handlers.js +10 -1
  65. data/lib/samagotchi/web/public/turn_html.js +5 -0
  66. data/lib/samagotchi/web/public/turn_view.js +16 -4
  67. data/lib/samagotchi/web/public/working_set.js +612 -0
  68. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  69. data/lib/samagotchi/web/public/working_set_view.js +541 -0
  70. data/lib/samagotchi/web/server.rb +6 -4
  71. data/lib/samagotchi/worker.rb +9 -6
  72. data/lib/samagotchi/working_set.rb +238 -0
  73. data/lib/samagotchi/working_set_command.rb +119 -0
  74. data/lib/samagotchi/working_set_steer.rb +250 -0
  75. metadata +11 -2
  76. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -16,6 +16,8 @@ require_relative "toolkit_command"
16
16
  require_relative "parent_answers_command"
17
17
  require_relative "title_command"
18
18
  require_relative "reasoning_history_command"
19
+ require_relative "working_set_command"
20
+ require_relative "recap_command"
19
21
  require_relative "guardrails/parent_policy"
20
22
 
21
23
  module Samagotchi
@@ -40,6 +42,8 @@ module Samagotchi
40
42
  PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
41
43
  TITLE_COMMAND = TitleCommand::NAME
42
44
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
45
+ WORKING_SET_COMMAND = WorkingSetCommand::NAME
46
+ RECAP_COMMAND = RecapCommand::NAME
43
47
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
44
48
  # shows this many per host; /models <text> lists every match.
45
49
  MODELS_PER_HOST = 20
@@ -109,6 +113,11 @@ module Samagotchi
109
113
  registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
110
114
  id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
111
115
  match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
116
+ # The list reads only: mid-turn too; a drop waits for the turn's end
117
+ # (the next turn's first request is the first with it).
118
+ registry.register(WORKING_SET_COMMAND, "list the working set; drop or undrop an item (drop KEY, undrop KEY)",
119
+ id: :working_set, mid_turn: show_mid_turn(WORKING_SET_COMMAND),
120
+ match: ->(text) { text.match?(%r{\A/working-set(?:\s+.*)?\z}) }) { |text| working_set(text) }
112
121
  # Tighten-only from anyone; loosening is a user's at a UI (#run's user_ui:).
113
122
  registry.register(PARENT_ANSWERS_COMMAND, "show or set what a parent agent may answer here (hard|standard|soft|default; " \
114
123
  "never looser than config.yml's)",
@@ -119,7 +128,14 @@ module Samagotchi
119
128
  id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
120
129
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
121
130
  registry.register("/stats", "show the session's stats", local: true)
122
- registry.register("/recap", "show the session's recap", local: true)
131
+ # /recap with words (on|off|default, or a slip: the usage) runs here:
132
+ # it toggles the idle job only, so it doesn't wait for a running turn.
133
+ # The bare /recap shows the recap: the UIs' own, under the same name
134
+ # (Registry#register); /help lists the name once, with this entry's
135
+ # description.
136
+ registry.register(RECAP_COMMAND, "show the session's recap (in a terminal); on|off|default sets this session's own",
137
+ id: :recap_setting, anytime: true, match: ->(text) { text.match?(%r{\A/recap\s+\S}) }) { |text| recap_setting(text) }
138
+ registry.register(RECAP_COMMAND, "show the session's recap", local: true, match: ->(text) { text == RECAP_COMMAND })
123
139
  # Bare `exit` too; any case; --delete after it.
124
140
  registry.register("/exit", "leave (--delete also deletes the session)", local: true,
125
141
  match: exit_match("/?exit"))
@@ -230,7 +246,9 @@ module Samagotchi
230
246
  # own (marked by UI), each group by name.
231
247
  # @return [String]
232
248
  def help_listing
233
- entries = @registry.entries.sort_by { |entry| [help_group(entry), entry.name] }
249
+ # A name with a UI's entry and the session's (/recap): listed once,
250
+ # as the session's (registered first).
251
+ entries = @registry.entries.uniq(&:name).sort_by { |entry| [help_group(entry), entry.name] }
234
252
  width = entries.map { |entry| self.class.display_name(entry).length }.max
235
253
  lines = entries.map do |entry|
236
254
  notes = []
@@ -527,6 +545,22 @@ module Samagotchi
527
545
  reply(output, status: status, changed: changed ? [:parent_answers] : [])
528
546
  end
529
547
 
548
+ # /working-set (WorkingSetCommand): an op changes the conversation
549
+ # (changed :messages: the worker saves it, the web reads it again), so
550
+ # a rollback past it would drop it silently (as !cmd), and the working
551
+ # set goes out again (held after the command's command_ran).
552
+ def working_set(text)
553
+ output, changed, status = WorkingSetCommand.new(engine: @engine, save: ->(_session) { save_session })
554
+ .run(text.delete_prefix(WORKING_SET_COMMAND))
555
+ if changed
556
+ @turn_flow.note_conversation_changed
557
+ @engine.announce_working_set
558
+ end
559
+ reply(output, status: status, changed: changed ? [:messages] : [])
560
+ rescue ArgumentError => e
561
+ reply(e.message, status: :error)
562
+ end
563
+
530
564
  # /title (TitleCommand); :title in changed when it named the session or
531
565
  # cleared its name. A plain save: no setting moved, and it may run on
532
566
  # an anytime thread beside a turn.
@@ -535,6 +569,14 @@ module Samagotchi
535
569
  reply(output, status: status, changed: changed ? [:title] : [])
536
570
  end
537
571
 
572
+ # /recap on|off|default (RecapCommand); :recap in changed when this
573
+ # session's own recap.enabled moved. It doesn't save: its host does on
574
+ # a changed result (Worker#run_command, the REPL).
575
+ def recap_setting(text)
576
+ output, changed, status = RecapCommand.new(engine: @engine).run(text.delete_prefix(RECAP_COMMAND))
577
+ reply(output, status: status, changed: changed ? [:recap] : [])
578
+ end
579
+
538
580
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
539
581
  # saved with its model.
540
582
  def save_setup(session)
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "version"
5
+ require_relative "config"
6
+
7
+ module Samagotchi
8
+ # A session's own settings (chi --set, chi send --new --set, POST
9
+ # /api/sessions settings), kept as one value (Settings). Its core part:
10
+ # {key => value} for the core config keys a session may set
11
+ # (Config::Entry#session?), typed by their entry (Config.coerce!) and
12
+ # read through Config.get(key, session:) by the readers wired to it
13
+ # (RecapSetup). Its
14
+ # bundles part: {bundle => {key => value}}, string keys, scalar values
15
+ # (.typed). A bundle offers the keys a session may set in its manifest's
16
+ # session_settings: (recorded at install, InstalledBundle#session_settings);
17
+ # any other bundle or key is refused, so a typo isn't saved, and so is a
18
+ # value of another type than the manifest gives the key (session_settings:
19
+ # {cap: number}). The session keeps them (Session#settings, SessionSetup)
20
+ # and Plugin::Context#settings lays them over config.yml's bundles: <name>:.
21
+ module SessionConfig
22
+ # A refused --set (or request field): the message says why.
23
+ class Error < ArgumentError; end
24
+
25
+ # A bundle name (MemoryBundle::Manifest::BUNDLE_NAME) and a setting key.
26
+ BUNDLE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
27
+ KEY = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
28
+ # The values a setting may take.
29
+ SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
30
+ REQUEST_SHAPE = "settings is an object of KEY or BUNDLE.KEY => scalar"
31
+ # What --set takes, as a malformed one is told.
32
+ FORMS = "KEY=VALUE or BUNDLE.KEY=VALUE"
33
+ # What .typed reads as a number: no leading zero, no _; a decimal may
34
+ # leave out either side of its point (.5, 5.) and take an exponent
35
+ # (1e1, 2.5e-3), which YAML 1.1 leaves as text.
36
+ INTEGER = /\A-?(?:0|[1-9]\d*)\z/
37
+ DECIMAL = /\A-?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
38
+ # YAML 1.1's booleans (config.yml's), any case.
39
+ TRUE_WORDS = %w[true yes on].freeze
40
+ FALSE_WORDS = %w[false no off].freeze
41
+ # The shipped bundles' sources (a stale record's hint reads them).
42
+ SHIPPED_DIR = File.expand_path("bundles", __dir__)
43
+
44
+ # A session's own settings, as one value: +core+ its core config keys
45
+ # ({key => value}, .normalize_core's shape), +bundles+ its bundle
46
+ # settings ({bundle => {key => value}}, .normalize's shape). Both
47
+ # frozen, string keys; the session file keeps +core+ under
48
+ # config_settings and +bundles+ under bundle_settings.
49
+ Settings = Data.define(:core, :bundles) do
50
+ def initialize(core: {}, bundles: {})
51
+ super(core: SessionConfig.normalize_core(core), bundles: SessionConfig.normalize(bundles))
52
+ end
53
+
54
+ # A Settings as given, nil as none (EMPTY).
55
+ # @param value [Settings, nil]
56
+ # @return [Settings]
57
+ # @raise [ArgumentError] anything else
58
+ def self.of(value)
59
+ return value if value.is_a?(Settings)
60
+ return Settings::EMPTY if value.nil?
61
+
62
+ raise ArgumentError, "session settings are a SessionConfig::Settings, not #{value.class}"
63
+ end
64
+
65
+ def empty? = core.empty? && bundles.empty?
66
+
67
+ # Both parts as --set names them: {"recap.enabled" => false,
68
+ # "cost-cap.cap" => 3} (/stats, chi sessions stats --format json).
69
+ # @return [Hash{String => Object}]
70
+ def flat
71
+ core.merge(bundles.flat_map { |bundle, values| values.map { |key, value| ["#{bundle}.#{key}", value] } }.to_h)
72
+ end
73
+
74
+ # "recap.enabled=false · cost-cap.cap=3", "" for none.
75
+ def text = SessionConfig.text(flat)
76
+ end
77
+
78
+ # The installed bundles a session may set settings of: +offered+ the
79
+ # keys of each bundle whose plugin loads ({name => keys}), +notes+ what
80
+ # a refusal adds for a bundle ({name => text}): why its plugin doesn't
81
+ # load, or how to refresh a record from before session_settings;
82
+ # +types+ the types its manifest gives keys ({name => {key => type}}).
83
+ Availability = Data.define(:offered, :notes, :types) do
84
+ def initialize(offered:, notes:, types: {}) = super
85
+ end
86
+
87
+ # What each type (MemoryBundle::Manifest::SESSION_SETTING_TYPES) takes,
88
+ # as a value .typed gives it, and how a refusal names it. A number may
89
+ # be off (false), as a bundle's own settings are turned off.
90
+ TYPES = {
91
+ "number" => [->(value) { (value.is_a?(Numeric) && value.finite?) || value == false }, "a number (or off)"],
92
+ "boolean" => [->(value) { [true, false].include?(value) }, "true or false"],
93
+ "string" => [->(value) { value.is_a?(String) }, "text"]
94
+ }.freeze
95
+
96
+ module_function
97
+
98
+ # --set's values as typed, in order: a later one of the same key wins.
99
+ # A name a core config entry has (recap.enabled), or one with no dot,
100
+ # is a core key (.core_value: a session-settable one, typed by its
101
+ # entry); any other is BUNDLE.KEY, its value .typed (a key +types+
102
+ # gives as string keeps its text as given, .value_for).
103
+ # @param args [Array<String>] "KEY=VALUE" or "BUNDLE.KEY=VALUE" each
104
+ # @param types [Hash{String => Hash{String => String}}] Availability#types
105
+ # @param bundles [Hash, nil] the offered keys an unknown dotless key's
106
+ # refusal names (.offered by default)
107
+ # @return [Settings]
108
+ # @raise [Error] a value of another shape, a core key no session sets
109
+ def parse(args, types: {}, bundles: nil)
110
+ core = {}
111
+ given = {}
112
+ Array(args).each do |arg|
113
+ target, sep, raw = arg.to_s.partition("=")
114
+ raise Error, "--set takes #{FORMS}, not #{arg}" if sep.empty?
115
+
116
+ name = split(target, "--set #{arg}") { raise Error, "--set takes #{FORMS}, not #{arg}" }
117
+ raise Error, "--set #{arg}: give a value" if raw.strip.empty?
118
+
119
+ if name.is_a?(String)
120
+ core[name] = core_value(name, raw, flag: "--set", bundles: bundles)
121
+ else
122
+ bundle, key = name
123
+ (given[bundle] ||= {})[key] = value_for(raw, types.dig(bundle, key))
124
+ end
125
+ end
126
+ Settings.new(core: core, bundles: given)
127
+ end
128
+
129
+ # +target+ (the part before =) as a core key (a String) or [bundle,
130
+ # key]; yields when it is no name at all.
131
+ def split(target, where)
132
+ return target if Config.find_by_key(target)
133
+
134
+ bundle, *keys = target.split(".", -1)
135
+ yield if keys.empty? ? !KEY.match?(bundle.to_s) : !BUNDLE.match?(bundle.to_s) || keys.any? { |key| !KEY.match?(key) }
136
+ return target if keys.empty?
137
+ raise Error, "#{where}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
138
+
139
+ [bundle, keys.first]
140
+ end
141
+
142
+ # A core key's value: +key+ names a session-settable entry, and +raw+
143
+ # types as it (Config.coerce!) and passes its check:.
144
+ # @raise [Error] "FLAG KEY: why"
145
+ def core_value(key, raw, flag:, bundles: nil)
146
+ entry = Config.find_by_key(key)
147
+ raise Error, "#{flag} #{key}: #{unknown_core(key, bundles: bundles)}" unless entry
148
+ raise Error, "#{flag} #{key}: #{key} is not a session setting (#{settable_text})" unless entry.session?
149
+
150
+ value = begin
151
+ Config.coerce!(entry, raw)
152
+ rescue Error => e
153
+ raise Error, "#{flag} #{key}: #{e.message}"
154
+ end
155
+ if (takes = entry.check&.call(value))
156
+ raise Error, "#{flag} #{key}: #{key} takes #{takes}, not #{raw.to_s.inspect}"
157
+ end
158
+
159
+ value
160
+ end
161
+
162
+ # Why +key+ (no config entry) is no session setting, with the settable
163
+ # keys it is near; a dotless or non-core-section key also names the
164
+ # bundles that take --set (it may have meant one).
165
+ def unknown_core(key, bundles: nil)
166
+ near = Config.near_names(key, Config.session_entries.map(&:key))
167
+ hint = near.empty? ? settable_text : "did you mean #{near.join(" or ")}?"
168
+ hint = "#{hint}; #{takers(bundles || offered)}" unless core_section?(key.split(".").first)
169
+ "no session setting #{key} (#{hint})"
170
+ end
171
+
172
+ # "session settings: recap.enabled, recap.sentences"
173
+ def settable_text = "session settings: #{Config.session_entries.map(&:key).join(", ")}"
174
+
175
+ # Whether +name+ is a core config section (recap, guardrails).
176
+ def core_section?(name) = Config::ENTRIES.any? { |entry| entry.yaml_path.size > 1 && entry.section == name }
177
+
178
+ # +settings+ (a Settings, or its bundles part) when every bundle is
179
+ # installed and offers each key; a bundle that isn't installed but
180
+ # names a core section (recap.enable) is a core key no session sets.
181
+ # Its core part was checked as it was typed (.parse, .from_request).
182
+ # @param bundles [Hash{String => Array<String>}] the installed bundles'
183
+ # offered keys (.offered)
184
+ # @param notes [Hash{String => String}] Availability#notes
185
+ # @param types [Hash{String => Hash{String => String}}] Availability#types:
186
+ # a typed key's value must be of its type (TYPES)
187
+ # @param flag [String] what a refusal names ("--set", a request field)
188
+ # @return [Settings, Hash] +settings+
189
+ # @raise [Error]
190
+ def check!(settings, bundles: offered, notes: {}, types: {}, flag: "--set")
191
+ (settings.is_a?(Settings) ? settings.bundles : settings).each do |bundle, values|
192
+ keys = bundles[bundle]
193
+ note = notes[bundle]
194
+ values.each do |key, value|
195
+ where = "#{flag} #{bundle}.#{key}"
196
+ if keys.nil?
197
+ raise Error, "#{where}: #{unknown_core("#{bundle}.#{key}", bundles: bundles)}" if note.nil? && core_section?(bundle)
198
+
199
+ raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}"
200
+ end
201
+ raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
202
+ raise Error, "#{where}: #{bundle} takes #{keys.join(", ")}" unless keys.include?(key)
203
+
204
+ takes, what = TYPES[types.dig(bundle, key)]
205
+ raise Error, "#{where}: #{bundle} takes #{what} for #{key}, not #{value.inspect}" if takes && !takes.call(value)
206
+ end
207
+ end
208
+ settings
209
+ end
210
+
211
+ # --set's values parsed and checked (.parse, .check!) against the
212
+ # installed bundles (.availability).
213
+ # @return [Settings]
214
+ def from_flags(args, available: availability)
215
+ check!(parse(args, types: available.types, bundles: available.offered), bundles: available.offered, notes: available.notes, types: available.types)
216
+ end
217
+
218
+ # A value as --set (and a request's string) gives it: an integer
219
+ # ("3", "-1"), a decimal ("2.5", ".5", "1e1"), a boolean as config.yml's
220
+ # YAML reads one (true/yes/on, false/no/off, any case); any other string
221
+ # stays as given ("010", "0x10", "3:30", "y", "null"). A value that
222
+ # isn't a String (a JSON number or boolean) is kept.
223
+ def typed(value)
224
+ return value unless value.is_a?(String)
225
+ return Integer(value, 10) if INTEGER.match?(value)
226
+
227
+ if DECIMAL.match?(value)
228
+ number = Float(float_text(value))
229
+ # 1e400 is Infinity: no cap reaches it and JSON can't save it.
230
+ return number.finite? ? number : value
231
+ end
232
+
233
+ word = value.downcase
234
+ return true if TRUE_WORDS.include?(word)
235
+ return false if FALSE_WORDS.include?(word)
236
+
237
+ value
238
+ end
239
+
240
+ # A value for a key of +type+: a string key's text as given (--set
241
+ # b.name=yes stays "yes"), any other's .typed.
242
+ def value_for(value, type) = type == "string" ? value : typed(value)
243
+
244
+ # A decimal DECIMAL matches as Float reads it on every Ruby: a point
245
+ # with no digit after it ("5.", "5.e1") gets a 0, which Ruby 3.3's
246
+ # Float wants.
247
+ def float_text(value) = value.sub(/\.(?=e|\z)/i, ".0")
248
+
249
+ # POST /api/sessions settings: nil for none, else an object of names
250
+ # as --set gives them (KEY or BUNDLE.KEY) to scalars (a string typed as
251
+ # --set types it), checked like --set.
252
+ # @param bundles [Hash, nil] the offered keys (.availability's by default)
253
+ # @param types [Hash] their types, with +bundles+ (Availability#types)
254
+ # @return [Settings]
255
+ # @raise [Error]
256
+ def from_request(raw, bundles: nil, types: {})
257
+ return Settings::EMPTY if raw.nil?
258
+ raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && scalar_map?(raw)
259
+ return Settings::EMPTY if raw.empty?
260
+
261
+ available = bundles ? Availability.new(offered: bundles, notes: {}, types: types) : availability
262
+ core = {}
263
+ given = {}
264
+ raw.each do |target, value|
265
+ target = target.to_s
266
+ name = split(target, "settings #{target}") { raise Error, "settings takes KEY or BUNDLE.KEY names, not #{target.inspect}" }
267
+ raise Error, "settings #{target}: give a value" if value.is_a?(String) && value.strip.empty?
268
+
269
+ if name.is_a?(String)
270
+ core[name] = core_value(name, value, flag: "settings", bundles: available.offered)
271
+ else
272
+ bundle, key = name
273
+ (given[bundle] ||= {})[key] = value_for(value, available.types.dig(bundle, key))
274
+ end
275
+ end
276
+ check!(Settings.new(core: core, bundles: given), bundles: available.offered, notes: available.notes,
277
+ types: available.types, flag: "settings")
278
+ end
279
+
280
+ # Each installed bundle's offered keys, by name (Availability#offered).
281
+ # @return [Hash{String => Array<String>}]
282
+ def offered(dir: nil) = availability(dir: dir).offered
283
+
284
+ # The installed bundles a session may set settings of: a record that
285
+ # doesn't read is left out, and so is one whose plugin doesn't load as
286
+ # chi's loader decides (Plugin::Loader#unloadable_reason: requires_chi,
287
+ # the file, its sha256), with a note saying why. A record with no
288
+ # session_settings whose shipped bundle lists some (installed before
289
+ # them) gets a note on refreshing it.
290
+ # @return [Availability]
291
+ def availability(dir: nil, shipped_dir: SHIPPED_DIR)
292
+ require_relative "memory_bundle/provenance"
293
+ require_relative "memory_bundle/manifest"
294
+ dir ||= MemoryBundle::Provenance.bundles_dir
295
+ offered = {}
296
+ notes = {}
297
+ types = {}
298
+ MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
299
+ next if bundle.error?
300
+
301
+ if (reason = unloadable(dir, name, bundle))
302
+ notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
303
+ next
304
+ end
305
+ offered[name] = bundle.session_settings
306
+ types[name] = bundle.session_setting_types unless bundle.session_setting_types.empty?
307
+ if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
308
+ notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
309
+ end
310
+ end
311
+ Availability.new(offered: offered, notes: notes, types: types)
312
+ end
313
+
314
+ # A session file's value, read tolerantly: the bundles that map to
315
+ # settings, the scalar ones of those, string keys, frozen; {} for
316
+ # anything else.
317
+ # @return [Hash{String => Hash{String => Object}}]
318
+ def normalize(raw)
319
+ return {}.freeze unless raw.is_a?(Hash)
320
+
321
+ deep_freeze(raw.each_with_object({}) do |(bundle, values), acc|
322
+ next unless values.is_a?(Hash)
323
+
324
+ kept = values.each_with_object({}) do |(key, value), scalars|
325
+ scalars[key.to_s] = value if SCALARS.any? { |kind| value.is_a?(kind) }
326
+ end
327
+ acc[bundle.to_s] = kept unless kept.empty?
328
+ end)
329
+ end
330
+
331
+ # Settings#core read tolerantly: the session-settable keys
332
+ # (Config::Entry#session?) whose value types as their entry and passes
333
+ # its check:, typed, string keys, frozen; {} for anything else (a key
334
+ # that is no longer settable, a hand-edited value).
335
+ # @return [Hash{String => Object}]
336
+ def normalize_core(raw)
337
+ return {}.freeze unless raw.is_a?(Hash)
338
+
339
+ raw.each_with_object({}) do |(key, value), kept|
340
+ entry = Config.find_by_key(key.to_s)
341
+ next unless entry&.session? && SCALARS.any? { |kind| value.is_a?(kind) }
342
+
343
+ typed = begin
344
+ Config.coerce!(entry, value)
345
+ rescue Error
346
+ next
347
+ end
348
+ next if typed.nil? || entry.check&.call(typed)
349
+
350
+ kept[entry.key] = typed.frozen? ? typed : typed.dup.freeze
351
+ end.freeze
352
+ end
353
+
354
+ # "KEY=VALUE · KEY=VALUE" for {key => value} (Settings#flat), "" for none.
355
+ def text(flat) = flat.map { |key, value| "#{key}=#{value}" }.join(" · ")
356
+
357
+ # Why a bundle +name+ can't offer the session settings +keys+: one
358
+ # whose NAME.KEY is a session-settable core key (--set NAME.KEY sets
359
+ # chi's), nil when none clashes. The install refuses it and chi bundle
360
+ # check reports it (clash), in these words.
361
+ # @return [String, nil]
362
+ def clash(name, keys)
363
+ clashing = Array(keys).map { |key| "#{name}.#{key}" } & Config.session_entries.map(&:key)
364
+ return nil if clashing.empty?
365
+
366
+ "session_settings: #{clashing.join(", ")} #{clashing.size == 1 ? "is" : "are"} chi's own session " \
367
+ "setting#{"s" unless clashing.size == 1} (--set #{clashing.first}=… sets chi's); rename the setting or the bundle"
368
+ end
369
+
370
+ # Why +bundle+'s plugin doesn't load, as chi's loader decides it
371
+ # (Plugin::Loader.unloadable_reason), nil when it does or has none.
372
+ def unloadable(dir, name, bundle)
373
+ return nil unless bundle.plugin
374
+
375
+ require_relative "plugin/loader"
376
+ # +dir+, not Provenance#plugin_path's bundles_dir: a spec's records live elsewhere.
377
+ Plugin::Loader.unloadable_reason(File.join(dir, name, "plugin", bundle.plugin.file.to_s), bundle)
378
+ end
379
+
380
+ # Whether chi ships a bundle +name+ whose manifest lists session_settings.
381
+ def shipped_settings?(shipped_dir, name)
382
+ path = File.join(shipped_dir, name, "manifest.yml")
383
+ return false unless File.file?(path)
384
+
385
+ raw = YAML.safe_load_file(path)
386
+ listed = raw.is_a?(Hash) ? raw["session_settings"] : nil
387
+ (listed.is_a?(Array) || listed.is_a?(Hash)) && !listed.empty?
388
+ rescue Psych::Exception, SystemCallError
389
+ false
390
+ end
391
+
392
+ def scalar_map?(values)
393
+ values.is_a?(Hash) && values.values.all? { |value| SCALARS.any? { |kind| value.is_a?(kind) } }
394
+ end
395
+
396
+ def takers(bundles)
397
+ names = bundles.reject { |_, keys| keys.empty? }.keys.sort
398
+ names.empty? ? "no installed bundle takes --set" : "bundles that take --set: #{names.join(", ")}"
399
+ end
400
+
401
+ def deep_freeze(settings)
402
+ settings.to_h { |bundle, values| [bundle.dup.freeze, values.to_h { |k, v| [k.dup.freeze, v.frozen? ? v : v.dup.freeze] }.freeze] }.freeze
403
+ end
404
+
405
+ private_class_method :unloadable, :shipped_settings?, :scalar_map?, :takers, :deep_freeze, :split, :unknown_core,
406
+ :settable_text, :core_section?
407
+
408
+ # No settings (built once the module's functions are defined).
409
+ Settings::EMPTY = Settings.new
410
+ end
411
+ end
@@ -4,7 +4,7 @@ require_relative "llm_context_override"
4
4
  require_relative "reasoning_history"
5
5
  require_relative "thinking"
6
6
  require_relative "toolkit"
7
- require_relative "bundle_session_settings"
7
+ require_relative "session_config"
8
8
 
9
9
  module Samagotchi
10
10
  # A session's own settings that come before its model's, carried as one
@@ -16,9 +16,9 @@ module Samagotchi
16
16
  # +appended_prompt_files+ the absolute paths chi --append-system-prompt
17
17
  # named (their text goes into the system prompt; [] with none),
18
18
  # +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
19
- # session config's toolkit.default then), +bundle_settings+ the bundle
20
- # settings chi --set gave ({bundle => {key => value}}, frozen; {} with
21
- # none), which Plugin::Context#settings lays over config.yml's,
19
+ # session config's toolkit.default then), +settings+ the session's own
20
+ # settings chi --set gave (SessionConfig::Settings; its bundles part is
21
+ # what Plugin::Context#settings lays over config.yml's),
22
22
  # +parent_answers+ the session's own guardrails.parent_answers (a level
23
23
  # String, tighten-only: Guardrails::ParentPolicy.effective) or nil.
24
24
  #
@@ -27,17 +27,17 @@ module Samagotchi
27
27
  # without one (but the toolkit its delegate call named, and its parent's
28
28
  # parent_answers); a continue and a fork copy their session's (.of).
29
29
  SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
30
- :bundle_settings, :parent_answers) do
30
+ :settings, :parent_answers) do
31
31
  def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
32
- bundle_settings: {}, parent_answers: nil)
32
+ settings: nil, parent_answers: nil)
33
33
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
34
34
  appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
35
- bundle_settings: BundleSessionSettings.normalize(bundle_settings), parent_answers: parent_answers&.to_s)
35
+ settings: SessionConfig::Settings.of(settings), parent_answers: parent_answers&.to_s)
36
36
  end
37
37
 
38
38
  def empty?
39
39
  llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
40
- bundle_settings.empty? && parent_answers.nil?
40
+ settings.empty? && parent_answers.nil?
41
41
  end
42
42
 
43
43
  # A session's own setup, for a session that starts from it. Its
@@ -50,7 +50,7 @@ module Samagotchi
50
50
 
51
51
  new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
52
52
  appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
53
- bundle_settings: session.bundle_settings, parent_answers: session.parent_answers)
53
+ settings: session.settings, parent_answers: session.parent_answers)
54
54
  end
55
55
  end
56
56
  end
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
40
40
  clean [--dry-run] [--days N] [--all] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
41
41
  --all: every session, not only test and scratch ones (by the age and count limits, as prune)
42
- stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
42
+ stats ID [--format text|json] # a session's cost, tokens, progress and own settings, without running a model turn:
43
43
  a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
44
44
  Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
45
45
  TEXT
@@ -323,10 +323,14 @@ module Samagotchi
323
323
  end
324
324
 
325
325
  metrics, live, note = stats_snapshot_for(session)
326
+ # The session's own settings (chi --set), as --set names them.
327
+ settings = session.settings.flat
326
328
  if format == "json"
327
- @stdout.puts JSON.generate({ session_id: session.id, status: session.status, live: live, metrics: metrics })
329
+ @stdout.puts JSON.generate({ session_id: session.id, status: session.status, live: live, metrics: metrics,
330
+ settings: settings })
328
331
  else
329
332
  @stdout.puts "#{session.id[0, 8]} #{session.status.ljust(7)} #{session.model_name}"
333
+ metrics = metrics.merge(settings: settings) if metrics && !settings.empty?
330
334
  @stdout.puts TerminalUI::Formatting.format_session_metrics(metrics)
331
335
  @stdout.puts note if note
332
336
  end
@@ -51,7 +51,7 @@ module Samagotchi
51
51
  # session keeps its own
52
52
  # @param toolkit_flags [Array<String>] which of those were given, for
53
53
  # the line an existing session's run prints (--set too)
54
- # @param bundle_settings [Hash] --set's values (BundleSessionSettings):
54
+ # @param settings [SessionConfig::Settings, nil] --set's values:
55
55
  # a new session's setup carries them; an existing session keeps its own
56
56
  # @param parent_answers [String, nil] --parent-answers: a new session's
57
57
  # own guardrails.parent_answers; an existing session keeps its own
@@ -64,12 +64,12 @@ module Samagotchi
64
64
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
65
65
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
66
66
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
67
- toolkit_flags: [], bundle_settings: {}, parent_answers: nil, state_dir: nil)
67
+ toolkit_flags: [], settings: nil, parent_answers: nil, state_dir: nil)
68
68
  state_dir ||= Session.default_state_dir
69
69
  client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
70
70
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
71
71
  appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
72
- bundle_settings: bundle_settings, parent_answers: parent_answers)
72
+ settings: settings, parent_answers: parent_answers)
73
73
  commands = []
74
74
  commands << "/model #{model}" if model && (attach || resume)
75
75
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
@@ -143,7 +143,7 @@ module Samagotchi
143
143
  # @raise [Error]
144
144
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
145
145
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
146
- toolkit_flags: [], bundle_settings: {}, parent_answers: nil, err: $stderr)
146
+ toolkit_flags: [], settings: nil, parent_answers: nil, err: $stderr)
147
147
  sd = state_dir || Session.default_state_dir
148
148
  if attach || resume
149
149
  warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
@@ -165,7 +165,7 @@ module Samagotchi
165
165
  setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
166
166
  thinking: Thinking.session_level(thinking),
167
167
  appended_prompt_files: appended_prompt_files,
168
- toolkit: toolkit, bundle_settings: bundle_settings,
168
+ toolkit: toolkit, settings: settings,
169
169
  parent_answers: parent_answers))
170
170
  end
171
171
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
@@ -968,7 +968,8 @@ module Samagotchi
968
968
  return @screen.commit("(no recap: the worker did not answer)") unless answer
969
969
 
970
970
  @screen.commit(recap_command_text(enabled: answer["enabled"], saved: answer["saved"],
971
- request: answer["request"], min_user_turns: answer["min_user_turns"]))
971
+ request: answer["request"], min_user_turns: answer["min_user_turns"],
972
+ session_off: answer["session_off"] == true))
972
973
  rescue SystemCallError, IOError
973
974
  @screen.commit("(no recap: the worker did not answer)")
974
975
  end