samagotchi 0.82.0 → 0.84.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +2 -2
  4. data/bin/chi +27 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +1 -0
  7. data/docs/configuration.md +52 -9
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +85 -3
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +13 -1
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/bundle_session_settings.rb +212 -0
  16. data/lib/samagotchi/bundles/core/manifest.yml +4 -2
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +13 -0
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +235 -0
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/child_reports.rb +25 -8
  21. data/lib/samagotchi/engine.rb +2 -1
  22. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  23. data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
  24. data/lib/samagotchi/memory_bundle/installer.rb +2 -1
  25. data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
  26. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  27. data/lib/samagotchi/plugin/context.rb +40 -3
  28. data/lib/samagotchi/reply_wait.rb +13 -15
  29. data/lib/samagotchi/send_command.rb +24 -7
  30. data/lib/samagotchi/session.rb +16 -1
  31. data/lib/samagotchi/session_metrics/cost.rb +37 -0
  32. data/lib/samagotchi/session_metrics.rb +9 -0
  33. data/lib/samagotchi/session_setup.rb +14 -6
  34. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
  35. data/lib/samagotchi/terminal_ui.rb +7 -2
  36. data/lib/samagotchi/testing/bundle.rb +31 -5
  37. data/lib/samagotchi/testing/host.rb +26 -1
  38. data/lib/samagotchi/testing/recorder.rb +4 -1
  39. data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
  40. data/lib/samagotchi/tools/delegate_wait.rb +40 -6
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +13 -4
  43. metadata +5 -1
@@ -41,6 +41,9 @@ module Samagotchi
41
41
  # cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
42
42
  # why: GitHub PR # optional
43
43
  # every_seconds: 300 # optional
44
+ # session_settings: [cap] # optional — the settings a session may set
45
+ # # (chi --set cost-cap.cap=3), read by the
46
+ # # plugin from ctx.settings at call time
44
47
  # includes: [a, b] # optional — a profile (meta bundle): the
45
48
  # # shipped bundles it installs; its dir holds
46
49
  # # only manifest.yml (MemoryBundle::Profile)
@@ -60,7 +63,7 @@ module Samagotchi
60
63
  # the description a mapping gave, for the files that have one; hooks:
61
64
  # name → BundleHook.
62
65
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
63
- :scripts, :context_providers
66
+ :scripts, :context_providers, :session_settings
64
67
 
65
68
  def initialize(path:)
66
69
  @path = path
@@ -84,6 +87,7 @@ module Samagotchi
84
87
  @scripts = parse_scripts(raw["scripts"])
85
88
  @context_providers = parse_context_providers(raw["context_providers"])
86
89
  @includes = parse_includes(raw["includes"])
90
+ @session_settings = self.class.parse_session_settings(raw["session_settings"])
87
91
  end
88
92
 
89
93
  # A profile: it installs the bundles in includes instead of files.
@@ -136,7 +140,8 @@ module Samagotchi
136
140
  # Writes a fresh manifest.yml from computed checksums at `dir`.
137
141
  # +file_descriptions+ (name → text) writes those files as mappings.
138
142
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
139
- plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {})
143
+ plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
144
+ session_settings: [])
140
145
  FileUtils.mkdir_p(dir)
141
146
  manifest = {
142
147
  "name" => name,
@@ -159,9 +164,26 @@ module Samagotchi
159
164
  manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
160
165
  manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
161
166
  manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
167
+ manifest["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
162
168
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
163
169
  end
164
170
 
171
+ # A setting key a session may set (BundleSessionSettings::KEY).
172
+ SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
173
+
174
+ # session_settings: → the keys, [] when absent; anything but a list of
175
+ # plain key names is a ValidationError.
176
+ def self.parse_session_settings(raw)
177
+ return [] if raw.nil?
178
+
179
+ keys = raw.is_a?(Array) ? raw : nil
180
+ unless keys&.all? { |key| (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(SESSION_SETTING) }
181
+ raise ValidationError, "session_settings: a list of setting names (#{raw.inspect})"
182
+ end
183
+
184
+ keys.map(&:to_s).uniq
185
+ end
186
+
165
187
  # needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
166
188
  # absent. Takes the manifest's string-keyed YAML or the symbol-keyed
167
189
  # form provenance stores. Duplicates merge (the first one's why/hint
@@ -141,7 +141,7 @@ module Samagotchi
141
141
  # @param context_providers [Array<ContextProviders::Provider>]
142
142
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
143
143
  guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
144
- scripts_files: {}, context_providers: [], file_descriptions: {})
144
+ scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [])
145
145
  FileUtils.mkdir_p(@bundle_dir)
146
146
  bases_dir = File.join(@bundle_dir, "bases")
147
147
  FileUtils.mkdir_p(bases_dir)
@@ -239,6 +239,7 @@ module Samagotchi
239
239
  end
240
240
  end
241
241
  manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
242
+ manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
242
243
 
243
244
  write_manifest(manifest_data)
244
245
  end
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require_relative "../log"
5
5
  require_relative "../paths"
6
+ require_relative "../session_metrics/cost"
6
7
  require_relative "../guardrails/context"
7
8
  require_relative "../idle_client"
8
9
  require_relative "service"
@@ -51,10 +52,11 @@ module Samagotchi
51
52
  # ctx.http call without cancel: uses, nil for none: an init task's own,
52
53
  # else the running turn's for work done for it (the turn's thread, or a
53
54
  # plugin handler: +handler+) until the turn has ended.
55
+ # +cost+ returns the session's spend now (SessionMetrics::Cost).
54
56
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
55
57
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
56
58
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
57
- :cancel_controller, keyword_init: true)
59
+ :cancel_controller, :cost, keyword_init: true)
58
60
 
59
61
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
60
62
  # sent nothing usable. The message says why, for the user.
@@ -95,8 +97,24 @@ module Samagotchi
95
97
  # @return [String] the bundle the plugin came with
96
98
  attr_reader :bundle
97
99
 
98
- # @return [Hash] config.yml `bundles: <bundle>:` (string keys), frozen
99
- attr_reader :settings
100
+ # config.yml `bundles: <bundle>:` with the session's own values for
101
+ # the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
102
+ # Host's setup: SessionSetup#bundle_settings), string keys, frozen.
103
+ # Plugin init and chi.ctx run before the session is bound: with no
104
+ # session id yet it is the config alone, and it is built again once
105
+ # per session id (the session's values never change after it starts).
106
+ # A plugin's initialize(settings) gets the config only: a value a
107
+ # session may set is read here, at call time.
108
+ # @return [Hash]
109
+ def settings
110
+ id = session_id
111
+ cached = @session_settings
112
+ return cached.last if cached && cached.first == id
113
+
114
+ merged = id ? overlay(@settings, session_values) : @settings
115
+ @session_settings = [id, merged].freeze
116
+ merged
117
+ end
100
118
 
101
119
  # @return [Logger]
102
120
  attr_reader :log
@@ -140,6 +158,11 @@ module Samagotchi
140
158
  # current_model_only names it
141
159
  def model_key = @host.model_key&.call
142
160
 
161
+ # @return [SessionMetrics::Cost] the session's spend so far, read now:
162
+ # #total, #reported, #estimated, #side (Float USD) and #known?
163
+ # (docs/plugins.md); zero and known with no session
164
+ def cost = @host.cost&.call || SessionMetrics::Cost.zero
165
+
143
166
  # @return [String, nil] the name of the session's toolkit ("full" for
144
167
  # one from before toolkits; nil with no session)
145
168
  def toolkit = @host.toolkit&.call
@@ -412,6 +435,20 @@ module Samagotchi
412
435
  nil
413
436
  end
414
437
 
438
+ # The session's own values for this bundle ({} without any, or when
439
+ # the Host can't tell them).
440
+ def session_values
441
+ values = @host.setup&.call&.bundle_settings
442
+ values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
443
+ rescue StandardError => e
444
+ @log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
445
+ {}
446
+ end
447
+
448
+ def overlay(config, values)
449
+ values.empty? ? config : deep_freeze(config.merge(values))
450
+ end
451
+
415
452
  def deep_freeze(value)
416
453
  case value
417
454
  when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
@@ -181,8 +181,9 @@ module Samagotchi
181
181
  (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
182
182
  baseline && ended == baseline[:last_turn]
183
183
  file = newest_reply(id, state_dir: state_dir)
184
- if file && reply_is_turns?(file, ended)
185
- reply = Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
184
+ path = File.join(reply_dir(id, state_dir: state_dir), file) if file
185
+ if file && reply_is_turns?(path, ended)
186
+ reply = Result.new(status: :done, text: File.read(path), file: file)
186
187
  reply.session = settled(session, id, state_dir: state_dir)
187
188
  return reply
188
189
  end
@@ -194,6 +195,9 @@ module Samagotchi
194
195
  !(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
195
196
  (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
196
197
  baseline && ended == baseline[:last_turn]
198
+ # The turn-specific reply lookup above already found its file, if
199
+ # one exists. Without it, don't use the newest file as a fallback:
200
+ # it could belong to another turn.
197
201
  return no_reply(session, baseline, fresh: true)
198
202
  end
199
203
 
@@ -440,19 +444,13 @@ module Samagotchi
440
444
  cursor.nil? || file > cursor
441
445
  end
442
446
 
443
- # Whether the reply file +file+ is the turn that ended at +ended_at+'s,
444
- # not an earlier turn's: the worker records the ended_at, then writes
445
- # the reply (SessionInbox.write_output: the name's timestamp is the
446
- # write time, at least the ended_at); every earlier turn's file is
447
- # older than the turn's end. The wait and the session run on one
448
- # machine, so the name's local-time read is the writer's. A name that
449
- # doesn't read as a time isn't the turn's.
450
- def reply_is_turns?(file, ended_at)
451
- name = file.to_s
452
- return false if name.length < 23
453
-
454
- Time.parse(ended_at.to_s) <= Time.strptime(name[0, 14], "%Y%m%d%H%M%S") + (name[14, 9].to_f / (10**9))
455
- rescue ArgumentError
447
+ # Whether the reply file at +path+ is the turn that ended at +ended_at+'s,
448
+ # not an earlier turn's: the worker records ended_at before writing the
449
+ # reply, so its mtime is at least the turn's end and earlier replies'
450
+ # mtimes precede it. Unlike the filename, mtime is independent of TZ.
451
+ def reply_is_turns?(path, ended_at)
452
+ File.mtime(path) >= Time.parse(ended_at.to_s)
453
+ rescue ArgumentError, SystemCallError
456
454
  false
457
455
  end
458
456
 
@@ -7,6 +7,7 @@ require_relative "session"
7
7
  require_relative "session_commands"
8
8
  require_relative "session_inbox"
9
9
  require_relative "session_manager"
10
+ require_relative "bundle_session_settings"
10
11
  require_relative "session_chain"
11
12
  require_relative "context_quote"
12
13
  require_relative "reply_wait"
@@ -39,7 +40,7 @@ module Samagotchi
39
40
 
40
41
  USAGE = <<~TEXT
41
42
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
42
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
43
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [-m TEXT] [--image PATH]...
43
44
  chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
44
45
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
45
46
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
@@ -65,8 +66,9 @@ module Samagotchi
65
66
  (--new) start the next link of that session's chain:
66
67
  in its folder, on its model (or --model's; one whose
67
68
  host is gone falls back to the default, with a
68
- warning), LLM context, thinking level and toolkit,
69
- with a note carrying its recap; it is archived.
69
+ warning), LLM context, thinking level, toolkit and
70
+ bundle settings, with a note carrying its recap; it
71
+ is archived.
70
72
  last:ID names the chain's latest link.
71
73
  Without -m the new session waits idle. Refused for a
72
74
  session continued already (naming the next link) or
@@ -84,6 +86,12 @@ module Samagotchi
84
86
  (full, lean, child, a config or bundle one; default
85
87
  config's toolkit.default), and tools declared on top
86
88
  of it or left out (repeatable, a comma list too)
89
+ --set BUNDLE.KEY=VALUE
90
+ (--new) a bundle setting for this session, over
91
+ config.yml's bundles: (repeatable; a key its bundle's
92
+ manifest lists in session_settings:; 3, 2.5, true,
93
+ false and off are typed, the rest is text:
94
+ --set cost-cap.cap=3)
87
95
  --wait wait for the answer and print it (one session); the
88
96
  other lines go to stderr. Exit 3: it waits for an
89
97
  answer from you: the question, its options and the
@@ -141,6 +149,7 @@ module Samagotchi
141
149
  f.value "--toolkit"
142
150
  f.value "--tool", key: :tools, repeat: true
143
151
  f.value "--no-tool", key: :no_tools, repeat: true
152
+ f.value "--set", key: :sets, repeat: true
144
153
  f.value "--image", key: :images, repeat: true
145
154
  f.switch "--wait"
146
155
  f.switch "--cut"
@@ -284,6 +293,7 @@ module Samagotchi
284
293
  %i[dir model continues thinking toolkit].each { |key| return usage_error("--#{key} needs --new") if options[key] }
285
294
  TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
286
295
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
296
+ return usage_error("--set needs --new") unless Array(options[:sets]).empty?
287
297
  return usage_error("give session ids") if options[:ids].empty?
288
298
 
289
299
  options
@@ -302,10 +312,11 @@ module Samagotchi
302
312
  if options[:continues]
303
313
  taken = [("--dir" if options[:dir])] +
304
314
  LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
305
- [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
315
+ [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
316
+ [("--set" unless Array(options[:sets]).empty?)]
306
317
  unless taken.compact.empty?
307
- return usage_error("--continues takes the previous session's folder, LLM context, thinking level and " \
308
- "toolkit; " \
318
+ return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
319
+ "toolkit and bundle settings; " \
309
320
  "leave out #{taken.compact.join(", ")}")
310
321
  end
311
322
  end
@@ -327,6 +338,11 @@ module Samagotchi
327
338
  end
328
339
  toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
329
340
  options[:toolkit_value] = toolkit if toolkit
341
+ begin
342
+ options[:bundle_settings] = BundleSessionSettings.from_flags(options[:sets]) unless Array(options[:sets]).empty?
343
+ rescue BundleSessionSettings::Error => e
344
+ return usage_error(e.message)
345
+ end
330
346
 
331
347
  if options[:dir]
332
348
  options[:dir] = File.expand_path(options[:dir])
@@ -376,7 +392,8 @@ module Samagotchi
376
392
  model_name: options[:model],
377
393
  setup: SessionSetup.new(llm_context: options[:llm_context],
378
394
  thinking: options[:thinking],
379
- toolkit: options[:toolkit_value]),
395
+ toolkit: options[:toolkit_value],
396
+ bundle_settings: options[:bundle_settings] || {}),
380
397
  state_dir: @state_dir)
381
398
  end
382
399
  rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -208,6 +208,18 @@ module Samagotchi
208
208
  @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
209
209
  end
210
210
 
211
+ # The session's own bundle settings (chi --set BUNDLE.KEY=VALUE):
212
+ # {bundle => {key => value}}, frozen, {} for none and for a session from
213
+ # before them. Given when it was created and never changed; a respawn,
214
+ # a continue and a fork keep them (SessionSetup.of), a delegate child
215
+ # doesn't. Plugin::Context#settings lays them over config.yml's.
216
+ attr_reader :bundle_settings
217
+
218
+ # @param value [Hash, nil] the session file's form too
219
+ def bundle_settings=(value)
220
+ @bundle_settings = BundleSessionSettings.normalize(value)
221
+ end
222
+
211
223
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
212
224
  # left), e.g. "loop-guard"; nil when it ended otherwise.
213
225
  def stopped_by
@@ -242,7 +254,7 @@ module Samagotchi
242
254
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
243
255
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
244
256
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
245
- toolkit: nil, interrupted_turn: nil)
257
+ toolkit: nil, bundle_settings: {}, interrupted_turn: nil)
246
258
  @id = id
247
259
  @metadata_version = metadata_version
248
260
  @mode = mode
@@ -272,6 +284,7 @@ module Samagotchi
272
284
  self.reasoning_history = reasoning_history
273
285
  self.appended_prompt_files = appended_prompt_files
274
286
  self.toolkit = toolkit
287
+ self.bundle_settings = bundle_settings
275
288
  self.interrupted_turn = interrupted_turn
276
289
  @turn_checkpoint = nil
277
290
  @archived = false
@@ -369,6 +382,7 @@ module Samagotchi
369
382
  reasoning_history: setup.reasoning_history,
370
383
  appended_prompt_files: setup.appended_prompt_files,
371
384
  toolkit: setup.toolkit,
385
+ bundle_settings: setup.bundle_settings,
372
386
  continues: continues
373
387
  )
374
388
  end
@@ -434,6 +448,7 @@ module Samagotchi
434
448
  "reasoning_history" => nil,
435
449
  "appended_prompt_files" => [],
436
450
  "toolkit" => nil,
451
+ "bundle_settings" => {},
437
452
  "interrupted_turn" => nil
438
453
  }.freeze
439
454
 
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionMetrics
5
+ # The session's spend so far (SessionMetrics#cost, what a bundle's
6
+ # ctx.cost reads), in USD: +reported+ by providers (usage.cost) and
7
+ # +estimated+ from configured prices (ModelPrice.estimate: a generation
8
+ # is one or the other, never both), each the main loop's and the side
9
+ # requests' together; +side+ is the side requests' share of the total
10
+ # (recap, /btw, ask_model). +known+ is false while generations or side
11
+ # requests ran but none was reported or priced: a cap can't be enforced
12
+ # on such a session. A session with some priced and some unpriced
13
+ # generations reads known (the unpriced ones count 0).
14
+ # Bundles use its methods only, never this constant's name.
15
+ Cost = Data.define(:reported, :estimated, :side, :known) do
16
+ def self.zero = new(reported: 0.0, estimated: 0.0, side: 0.0, known: true)
17
+
18
+ # @param totals [Totals] the session's sums, the running turn folded in
19
+ # @param side [SideTotals]
20
+ # @return [Cost]
21
+ def self.of(totals, side)
22
+ side_reported = side.cost_sum.to_f
23
+ side_estimated = side.cost_estimate_sum.to_f
24
+ reported = totals.cost_sum.to_f + side_reported
25
+ estimated = totals.cost_estimate_sum.to_f + side_estimated
26
+ ran = (totals.prompt_sum + totals.completion_sum).positive? || side.requests.positive?
27
+ new(reported: reported, estimated: estimated, side: side_reported + side_estimated,
28
+ known: !ran || reported.positive? || estimated.positive?)
29
+ end
30
+
31
+ # @return [Float] reported + estimated, side requests included
32
+ def total = reported + estimated
33
+
34
+ def known? = known
35
+ end
36
+ end
37
+ end
@@ -12,6 +12,7 @@ require_relative "model_price"
12
12
  require_relative "session"
13
13
  require_relative "session_metrics/totals"
14
14
  require_relative "session_metrics/model_segment"
15
+ require_relative "session_metrics/cost"
15
16
  require_relative "session_metrics/side_totals"
16
17
  require_relative "side_usage"
17
18
 
@@ -365,6 +366,14 @@ module Samagotchi
365
366
  end
366
367
  end
367
368
 
369
+ # The session's spend now (ctx.cost): its turns' sums with the running
370
+ # turn's finished generations folded in, and its side requests.
371
+ # Safe from any thread.
372
+ # @return [Cost]
373
+ def cost
374
+ @mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
375
+ end
376
+
368
377
  # Event sink. Safe to call from any thread; errors are isolated by the
369
378
  # caller (SessionObserver / TerminalUI) but we still guard internally.
370
379
  # @param event [Hash]
@@ -4,6 +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
8
 
8
9
  module Samagotchi
9
10
  # A session's own settings that come before its model's, carried as one
@@ -15,20 +16,26 @@ module Samagotchi
15
16
  # +appended_prompt_files+ the absolute paths chi --append-system-prompt
16
17
  # named (their text goes into the system prompt; [] with none),
17
18
  # +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
18
- # session config's toolkit.default then).
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
22
  #
20
23
  # It doesn't save itself: the session keeps each setting in its own field
21
24
  # and file key, and resolves it on its own. A delegate child starts
22
25
  # without one (but the toolkit its delegate call named); a continue and a
23
26
  # fork copy their session's (.of).
24
- SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit) do
25
- def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
27
+ SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
28
+ :bundle_settings) do
29
+ def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
30
+ bundle_settings: {})
26
31
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
27
- appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit)
32
+ appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
33
+ bundle_settings: BundleSessionSettings.normalize(bundle_settings))
28
34
  end
29
35
 
30
36
  def empty?
31
- llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil?
37
+ llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
38
+ bundle_settings.empty?
32
39
  end
33
40
 
34
41
  # A session's own setup, for a session that starts from it. Its
@@ -40,7 +47,8 @@ module Samagotchi
40
47
  return new if session.nil?
41
48
 
42
49
  new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
43
- appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit))
50
+ appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
51
+ bundle_settings: session.bundle_settings)
44
52
  end
45
53
  end
46
54
  end
@@ -50,7 +50,9 @@ module Samagotchi
50
50
  # session's toolkit (config's toolkit.default without); an existing
51
51
  # session keeps its own
52
52
  # @param toolkit_flags [Array<String>] which of those were given, for
53
- # the line an existing session's run prints
53
+ # the line an existing session's run prints (--set too)
54
+ # @param bundle_settings [Hash] --set's values (BundleSessionSettings):
55
+ # a new session's setup carries them; an existing session keeps its own
54
56
  # @param state_dir [String, nil] where the sessions are (the default
55
57
  # state dir by default): the worker's, for the attached loop too
56
58
  # @return [Symbol] :detached, :closed when the worker went away,
@@ -58,11 +60,12 @@ module Samagotchi
58
60
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
59
61
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
60
62
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
61
- toolkit_flags: [], state_dir: nil)
63
+ toolkit_flags: [], bundle_settings: {}, state_dir: nil)
62
64
  state_dir ||= Session.default_state_dir
63
65
  client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
64
66
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
65
- appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags)
67
+ appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
68
+ bundle_settings: bundle_settings)
66
69
  commands = []
67
70
  commands << "/model #{model}" if model && (attach || resume)
68
71
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
@@ -131,7 +134,7 @@ module Samagotchi
131
134
  # @raise [Error]
132
135
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
133
136
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
134
- toolkit_flags: [], err: $stderr)
137
+ toolkit_flags: [], bundle_settings: {}, err: $stderr)
135
138
  sd = state_dir || Session.default_state_dir
136
139
  if attach || resume
137
140
  warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
@@ -153,7 +156,7 @@ module Samagotchi
153
156
  setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
154
157
  thinking: Thinking.session_level(thinking),
155
158
  appended_prompt_files: appended_prompt_files,
156
- toolkit: toolkit))
159
+ toolkit: toolkit, bundle_settings: bundle_settings))
157
160
  end
158
161
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
159
162
  client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
@@ -100,12 +100,16 @@ module Samagotchi
100
100
  # @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
101
101
  # session's toolkit (config's toolkit.default without); a resumed one
102
102
  # keeps its own, with a line naming +toolkit_flags+ (the flags given)
103
+ # @param bundle_settings [Hash] --set's values (BundleSessionSettings):
104
+ # a new session's own; a resumed one keeps its own (--set is in
105
+ # +toolkit_flags+ then)
103
106
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
104
107
  spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
105
- appended_prompt_files: [], toolkit: nil, toolkit_flags: [])
108
+ appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {})
106
109
  @scratch = scratch
107
110
  @toolkit = toolkit
108
111
  @toolkit_flags = toolkit_flags
112
+ @bundle_settings = bundle_settings
109
113
  @thinking_word = thinking
110
114
  @prompt = prompt
111
115
  @llm_context_words = llm_context
@@ -274,7 +278,8 @@ module Samagotchi
274
278
  preloaded_memory_names: @requested_memories,
275
279
  muted_memory_names: @muted_memory_names,
276
280
  scratch: @scratch,
277
- setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured)
281
+ setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
282
+ bundle_settings: @bundle_settings)
278
283
  ))
279
284
  # --llm-context*: the session's own values, over a resumed one's.
280
285
  # Saved at once on a resumed one: leaving before a turn keeps them.
@@ -11,6 +11,7 @@ require_relative "../peer_sources"
11
11
  require_relative "../toolkit"
12
12
  require_relative "../guardrails/gate"
13
13
  require_relative "../memory_bundle/manifest"
14
+ require_relative "../session_setup"
14
15
  require_relative "sandbox"
15
16
  require_relative "recorder"
16
17
  require_relative "host"
@@ -74,11 +75,15 @@ module Samagotchi
74
75
  # @param dir [String] the bundle's folder (its manifest.yml)
75
76
  # @param settings [Hash] config.yml `bundles: <name>:`
76
77
  # @param host [Hash] session values (Recorder::DEFAULTS' keys)
78
+ # @param session_settings [Hash] the session's own values for the
79
+ # bundle (chi --set), keys its manifest lists in session_settings:;
80
+ # ctx.settings lays them over +settings+ (Plugin::Context#settings),
81
+ # initialize(settings) doesn't see them
77
82
  # @param sandbox [Sandbox, nil] default Sandbox.current, else one of
78
83
  # its own, left at #shutdown
79
84
  # @return [Bundle]
80
- def load(dir, settings: {}, host: {}, sandbox: nil)
81
- new(dir, settings: settings, host: host, sandbox: sandbox).tap(&:load!)
85
+ def load(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
86
+ new(dir, settings: settings, host: host, session_settings: session_settings, sandbox: sandbox).tap(&:load!)
82
87
  end
83
88
 
84
89
  # A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
@@ -118,14 +123,16 @@ module Samagotchi
118
123
  # @return [Integer] how often chi was told the tools changed
119
124
  attr_reader :tools_changes
120
125
 
121
- def initialize(dir, settings: {}, host: {}, sandbox: nil)
126
+ def initialize(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
122
127
  @dir = File.expand_path(dir)
123
128
  @manifest = MemoryBundle::Manifest.read(dir: @dir)
124
129
  @name = @manifest.name
125
130
  @settings = stringify(settings || {})
131
+ @session_moves = 1
132
+ own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
126
133
  @own_sandbox = sandbox.nil? && Sandbox.current.nil?
127
134
  @sandbox = sandbox || Sandbox.current || Sandbox.enter
128
- @recorder = Recorder.new(cwd: @sandbox.work_dir, **host)
135
+ @recorder = Recorder.new(cwd: @sandbox.work_dir, **host, **own)
129
136
  @sessions = Sessions.new
130
137
  @http = FakeHttp.new
131
138
  @inits = []
@@ -309,7 +316,16 @@ module Samagotchi
309
316
  end
310
317
 
311
318
  # Change the session's values (cwd:, messages:, running: …).
312
- def set(**values)
319
+ # +session_settings+ changes the session's own values for the bundle
320
+ # (as Bundle.load's) and moves it to a new session id
321
+ # ("test-session-2", or the session_id: given beside it): chi never
322
+ # changes them within a session, and ctx.settings is built once per
323
+ # session id.
324
+ def set(session_settings: nil, **values)
325
+ unless session_settings.nil?
326
+ values[:setup] = setup_with(session_settings, @recorder[:setup])
327
+ values[:session_id] ||= "#{@recorder[:session_id] || "test-session"}-#{@session_moves += 1}"
328
+ end
313
329
  @recorder.set(**values)
314
330
  self
315
331
  end
@@ -363,6 +379,16 @@ module Samagotchi
363
379
  result
364
380
  end
365
381
 
382
+ # +setup+ (the recorder's, nil for none) with +session_settings+ as
383
+ # this bundle's own values, checked against the manifest's
384
+ # session_settings: as chi --set checks them.
385
+ # @raise [ArgumentError] a key the manifest doesn't list
386
+ def setup_with(session_settings, setup)
387
+ values = { @name => stringify(session_settings) }
388
+ BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings }, flag: "session_settings")
389
+ (setup || SessionSetup.new).with(bundle_settings: values)
390
+ end
391
+
366
392
  def stringify(value)
367
393
  case value
368
394
  when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }