samagotchi 0.83.0 → 0.85.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/bin/chi +27 -2
  4. data/docs/architecture.md +10 -1
  5. data/docs/cli.md +17 -1
  6. data/docs/configuration.md +2 -0
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +3 -2
  10. data/docs/plugins.md +31 -5
  11. data/docs/sessions.md +15 -2
  12. data/docs/sub-agent.md +5 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  14. data/lib/samagotchi/bridge.rb +54 -1
  15. data/lib/samagotchi/bridge_client.rb +12 -0
  16. data/lib/samagotchi/bundle_session_settings.rb +212 -0
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -2
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +12 -5
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/engine.rb +1 -1
  21. data/lib/samagotchi/kernel_loop.rb +5 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  23. data/lib/samagotchi/log_subscriber.rb +1 -0
  24. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  25. data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
  26. data/lib/samagotchi/memory_bundle/installer.rb +2 -1
  27. data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
  28. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  29. data/lib/samagotchi/parent_report.rb +5 -2
  30. data/lib/samagotchi/plugin/context.rb +36 -2
  31. data/lib/samagotchi/reply_wait.rb +8 -1
  32. data/lib/samagotchi/send_command.rb +24 -7
  33. data/lib/samagotchi/session.rb +16 -1
  34. data/lib/samagotchi/session_commands.rb +7 -4
  35. data/lib/samagotchi/session_inbox.rb +68 -0
  36. data/lib/samagotchi/session_setup.rb +14 -6
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
  39. data/lib/samagotchi/terminal_ui.rb +10 -2
  40. data/lib/samagotchi/testing/bundle.rb +31 -5
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +41 -4
  43. data/lib/samagotchi/web/public/app.js +17 -0
  44. data/lib/samagotchi/web/public/bubbles.js +9 -0
  45. data/lib/samagotchi/web/public/commands.js +1 -0
  46. data/lib/samagotchi/web/public/data.js +11 -0
  47. data/lib/samagotchi/web/public/index.html +9 -0
  48. data/lib/samagotchi/web/public/stage_view.js +6 -0
  49. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  50. data/lib/samagotchi/web/public/turn_events.js +4 -0
  51. data/lib/samagotchi/web/public/turn_html.js +9 -1
  52. data/lib/samagotchi/web/public/unqueue.js +56 -0
  53. metadata +3 -1
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "version"
5
+
6
+ module Samagotchi
7
+ # A session's own bundle settings (chi --set BUNDLE.KEY=VALUE, chi send
8
+ # --new --set, POST /api/sessions bundle_settings): {bundle => {key =>
9
+ # value}}, string keys, scalar values (.typed). A bundle offers the keys a
10
+ # session may set in its manifest's session_settings: (recorded at install,
11
+ # InstalledBundle#session_settings); any other bundle or key is refused, so
12
+ # a typo isn't saved. The session keeps them (Session#bundle_settings,
13
+ # SessionSetup) and Plugin::Context#settings lays them over config.yml's
14
+ # bundles: <name>:.
15
+ module BundleSessionSettings
16
+ # A refused --set (or request field): the message says why.
17
+ class Error < ArgumentError; end
18
+
19
+ # A bundle name (MemoryBundle::Manifest::BUNDLE_NAME) and a setting key.
20
+ BUNDLE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
21
+ KEY = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
22
+ # The values a setting may take.
23
+ SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
24
+ REQUEST_SHAPE = "bundle_settings is an object of bundle => {key => scalar}"
25
+ # What .typed reads as a number: no leading zero, no exponent, no _.
26
+ INTEGER = /\A-?(?:0|[1-9]\d*)\z/
27
+ DECIMAL = /\A-?(?:0|[1-9]\d*)\.\d+\z/
28
+ # The shipped bundles' sources (a stale record's hint reads them).
29
+ SHIPPED_DIR = File.expand_path("bundles", __dir__)
30
+
31
+ # The installed bundles a session may set settings of: +offered+ the
32
+ # keys of each bundle whose plugin loads ({name => keys}), +notes+ what
33
+ # a refusal adds for a bundle ({name => text}): why its plugin doesn't
34
+ # load, or how to refresh a record from before session_settings.
35
+ Availability = Data.define(:offered, :notes)
36
+
37
+ module_function
38
+
39
+ # --set's values as typed, in order: a later one of the same key wins.
40
+ # @param args [Array<String>] "BUNDLE.KEY=VALUE" each
41
+ # @return [Hash{String => Hash{String => Object}}] frozen
42
+ # @raise [Error] a value of another shape
43
+ def parse(args)
44
+ settings = {}
45
+ Array(args).each do |arg|
46
+ target, sep, raw = arg.to_s.partition("=")
47
+ bundle, *keys = target.split(".", -1)
48
+ if sep.empty? || keys.empty? || !BUNDLE.match?(bundle.to_s) || keys.any? { |key| !KEY.match?(key) }
49
+ raise Error, "--set takes BUNDLE.KEY=VALUE, not #{arg}"
50
+ end
51
+ raise Error, "--set #{arg}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
52
+ raise Error, "--set #{arg}: give a value" if raw.strip.empty?
53
+
54
+ (settings[bundle] ||= {})[keys.first] = typed(raw)
55
+ end
56
+ deep_freeze(settings)
57
+ end
58
+
59
+ # +settings+ when every bundle is installed and offers each key.
60
+ # @param bundles [Hash{String => Array<String>}] the installed bundles'
61
+ # offered keys (.offered)
62
+ # @param notes [Hash{String => String}] Availability#notes
63
+ # @param flag [String] what a refusal names ("--set", a request field)
64
+ # @return [Hash] +settings+
65
+ # @raise [Error]
66
+ def check!(settings, bundles: offered, notes: {}, flag: "--set")
67
+ settings.each do |bundle, values|
68
+ keys = bundles[bundle]
69
+ note = notes[bundle]
70
+ values.each_key do |key|
71
+ where = "#{flag} #{bundle}.#{key}"
72
+ raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}" if keys.nil?
73
+ raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
74
+ raise Error, "#{where}: #{bundle} takes #{keys.join(", ")}" unless keys.include?(key)
75
+ end
76
+ end
77
+ settings
78
+ end
79
+
80
+ # --set's values parsed and checked (.parse, .check!) against the
81
+ # installed bundles (.availability).
82
+ def from_flags(args, available: availability)
83
+ check!(parse(args), bundles: available.offered, notes: available.notes)
84
+ end
85
+
86
+ # A value as --set (and a request's string) gives it: an integer
87
+ # ("3", "-1"), a decimal ("2.5"), true, false or off (false); any other
88
+ # string stays as given ("010", "0x10", "3:30", "No", "null"). A value
89
+ # that isn't a String (a JSON number or boolean) is kept.
90
+ def typed(value)
91
+ return value unless value.is_a?(String)
92
+
93
+ case value
94
+ when INTEGER then Integer(value, 10)
95
+ when DECIMAL then Float(value)
96
+ when "true" then true
97
+ when "false", "off" then false
98
+ else value
99
+ end
100
+ end
101
+
102
+ # POST /api/sessions bundle_settings: nil for none, else an object of
103
+ # objects of scalars (a string typed as --set's, .typed), checked like
104
+ # --set; an empty bundle entry is dropped.
105
+ # @param bundles [Hash, nil] the offered keys (.availability's by default)
106
+ # @return [Hash] frozen
107
+ # @raise [Error]
108
+ def from_request(raw, bundles: nil)
109
+ return {} if raw.nil?
110
+ raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && raw.all? { |_, values| scalar_map?(values) }
111
+
112
+ settings = normalize(raw.transform_values { |values| values.transform_values { |value| typed(value) } })
113
+ return settings if settings.empty?
114
+
115
+ available = bundles ? Availability.new(offered: bundles, notes: {}) : availability
116
+ check!(settings, bundles: available.offered, notes: available.notes, flag: "bundle_settings")
117
+ end
118
+
119
+ # Each installed bundle's offered keys, by name (Availability#offered).
120
+ # @return [Hash{String => Array<String>}]
121
+ def offered(dir: nil) = availability(dir: dir).offered
122
+
123
+ # The installed bundles a session may set settings of: a record that
124
+ # doesn't read is left out, and so is one whose plugin doesn't load as
125
+ # chi's loader decides (Plugin::Loader#unloadable_reason: requires_chi,
126
+ # the file, its sha256), with a note saying why. A record with no
127
+ # session_settings whose shipped bundle lists some (installed before
128
+ # them) gets a note on refreshing it.
129
+ # @return [Availability]
130
+ def availability(dir: nil, shipped_dir: SHIPPED_DIR)
131
+ require_relative "memory_bundle/provenance"
132
+ require_relative "memory_bundle/manifest"
133
+ dir ||= MemoryBundle::Provenance.bundles_dir
134
+ offered = {}
135
+ notes = {}
136
+ MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
137
+ next if bundle.error?
138
+
139
+ if (reason = unloadable(dir, name, bundle))
140
+ notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
141
+ next
142
+ end
143
+ offered[name] = bundle.session_settings
144
+ if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
145
+ notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
146
+ end
147
+ end
148
+ Availability.new(offered: offered, notes: notes)
149
+ end
150
+
151
+ # A session file's value, read tolerantly: the bundles that map to
152
+ # settings, the scalar ones of those, string keys, frozen; {} for
153
+ # anything else.
154
+ # @return [Hash{String => Hash{String => Object}}]
155
+ def normalize(raw)
156
+ return {}.freeze unless raw.is_a?(Hash)
157
+
158
+ deep_freeze(raw.each_with_object({}) do |(bundle, values), acc|
159
+ next unless values.is_a?(Hash)
160
+
161
+ kept = values.each_with_object({}) do |(key, value), scalars|
162
+ scalars[key.to_s] = value if SCALARS.any? { |kind| value.is_a?(kind) }
163
+ end
164
+ acc[bundle.to_s] = kept unless kept.empty?
165
+ end)
166
+ end
167
+
168
+ # Why +bundle+'s plugin doesn't load (as Plugin::Loader checks it), nil
169
+ # when it does or has none.
170
+ def unloadable(dir, name, bundle)
171
+ return nil unless bundle.plugin
172
+
173
+ failure = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
174
+ return failure if failure
175
+
176
+ file = File.join(dir, name, "plugin", bundle.plugin.file.to_s)
177
+ return "the file is missing" unless File.file?(file)
178
+
179
+ expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
180
+ return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
181
+
182
+ actual = MemoryBundle::Provenance.file_sha(file)
183
+ "its sha256 differs from the installed one (edited after install? reinstall the bundle)" unless actual == expected
184
+ end
185
+
186
+ # Whether chi ships a bundle +name+ whose manifest lists session_settings.
187
+ def shipped_settings?(shipped_dir, name)
188
+ path = File.join(shipped_dir, name, "manifest.yml")
189
+ return false unless File.file?(path)
190
+
191
+ raw = YAML.safe_load_file(path)
192
+ raw.is_a?(Hash) && raw["session_settings"].is_a?(Array) && !raw["session_settings"].empty?
193
+ rescue StandardError
194
+ false
195
+ end
196
+
197
+ def scalar_map?(values)
198
+ values.is_a?(Hash) && values.values.all? { |value| SCALARS.any? { |kind| value.is_a?(kind) } }
199
+ end
200
+
201
+ def takers(bundles)
202
+ names = bundles.reject { |_, keys| keys.empty? }.keys.sort
203
+ names.empty? ? "no installed bundle takes --set" : "bundles that take --set: #{names.join(", ")}"
204
+ end
205
+
206
+ def deep_freeze(settings)
207
+ settings.to_h { |bundle, values| [bundle.dup.freeze, values.to_h { |k, v| [k.dup.freeze, v.frozen? ? v : v.dup.freeze] }.freeze] }.freeze
208
+ end
209
+
210
+ private_class_method :unloadable, :shipped_settings?, :scalar_map?, :takers, :deep_freeze
211
+ end
212
+ end
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  name: cost-cap
3
- version: 0.1.0
3
+ version: 0.1.1
4
4
  scope: system
5
5
  description: "A per-session $ cap: at the cap the turn pauses on Continue (another cap's worth) / Stop, a warning at 80% goes to the user only; /cap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:bc6683af0063b90d6a3d7c2796bccf446043dfb1b3f07c04bd54d22d26199e1d
9
+ sha256: sha256:2ea7f76af1ea1c1b0a9a8b0b4b11d6e85a4a816fae224ef5b220cfd20fb48f42
10
10
  # ctx.cost is new in chi 0.83.0
11
11
  requires_chi: ">= 0.83.0"
12
+ # chi --set cost-cap.cap=3: a session's own cap (ctx.settings["cap"])
13
+ session_settings: [cap]
@@ -10,13 +10,16 @@
10
10
  #
11
11
  # Settings (config.yml, bundles: cost-cap:), read at each use from
12
12
  # ctx.settings, never at load:
13
+ # cap: 3.00 this session's own cap, set with chi --set cost-cap.cap=3
14
+ # (session_settings: in the manifest); 0/off: none
13
15
  # default: 5.00 USD cap for a session with none set; absent/0/off: none
14
16
  # child: 2.00 for delegate children (ctx.delegate?); absent: default
15
17
  # warn_at: 0.8 the share of the cap that warns; 0/off: no warning
16
18
  #
17
19
  # State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
18
20
  # written by /cap and by Continue; "cap": null (or false) is off for the
19
- # session, over the config. Without a file the cap comes from the settings.
21
+ # session, over the config. Without a file the cap comes from the settings:
22
+ # cap:, else child: for a delegate, else default:.
20
23
  # The cap is resolved once per session id (ctx.delegate? parses the session
21
24
  # file) and kept until /cap or Continue writes a new one.
22
25
  require "json"
@@ -164,8 +167,9 @@ class Plugin
164
167
 
165
168
  def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
166
169
 
167
- # The session's cap and step now: the state file's, else child: for a
168
- # delegate, else default:. Cached per session id.
170
+ # The session's cap and step now: the state file's, else the session's
171
+ # own cap: (--set), else child: for a delegate, else default:. Cached per
172
+ # session id.
169
173
  def resolve(ctx)
170
174
  session = ctx.session_id
171
175
  cached = @mutex.synchronize { @resolved }
@@ -189,9 +193,12 @@ class Plugin
189
193
 
190
194
  def configured(ctx)
191
195
  settings = ctx.settings
192
- return { cap: nil, step: nil } unless settings.key?("default") || settings.key?("child")
196
+ return { cap: nil, step: nil } unless %w[cap default child].any? { |key| settings.key?(key) }
193
197
 
194
- key = settings.key?("child") && ctx.delegate? ? "child" : "default"
198
+ key = if settings.key?("cap") then "cap"
199
+ elsif settings.key?("child") && ctx.delegate? then "child"
200
+ else "default"
201
+ end
195
202
  cap = self.class.amount(settings[key])
196
203
  { cap: cap, step: cap }
197
204
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.83.0
3
+ version: 0.85.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -762,7 +762,7 @@ module Samagotchi
762
762
  # Event types #announce accepts: facts about the session's input queue,
763
763
  # a failed turn's prompt handed back and the continue offer, which live
764
764
  # UIs need in the event log, emitted outside any turn's stream.
765
- ANNOUNCEABLE_EVENTS = %i[turn_enqueued input_merged prompt_restored continue_offered continue_resolved
765
+ ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
766
766
  command_queued command_ran context_added card hook_notice guardrail_warning
767
767
  plugin_init_started plugin_init_finished].freeze
768
768
 
@@ -372,6 +372,11 @@ module Samagotchi
372
372
  # Ends with its :generation_completed (and, unless cut, the
373
373
  # after_generation hook).
374
374
  def generate(turn, request)
375
+ # Before the generation starts: a hook that waits (a question) isn't
376
+ # the generation's time, and a Stop there leaves none started.
377
+ fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
378
+ raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
379
+
375
380
  # Each ask splits its own stream (run_generation rebuilds the
376
381
  # splitter), so one that ended inside a thought or a tool call doesn't
377
382
  # leave the next one "inside" it.
@@ -383,7 +388,6 @@ module Samagotchi
383
388
  # streamed, for the log (a stuck thinking generation shows as
384
389
  # thinking_chars=N content_length=…).
385
390
  generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
386
- fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
387
391
  buffer_mark = generation.buffer_mark = turn.buffer.length
388
392
  # A cancel salvages only this generation's thinking: the earlier
389
393
  # steps' is on their own messages already.
@@ -655,10 +655,14 @@ module Samagotchi
655
655
  @loop.apply_llm_context!(@conversation, context: @context, emit: method(:emit))
656
656
  window = @window = @loop.context_window(@model_name)
657
657
  observe_context(iteration, window)
658
+ # Before the generation starts: a hook that waits (a question)
659
+ # isn't the generation's time, and a Stop there leaves none started.
660
+ @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
661
+ return [@cancel_controller.reason, "", ""] if @cancel_controller&.cancelled?
662
+
658
663
  retry_generation = @empty_retry.take_sampling!
659
664
  emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
660
665
  context_window_source: window&.source, price: @loop.price&.to_h)
661
- @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
662
666
  streamed = +""
663
667
  thought = +""
664
668
  generation_controller = nil
@@ -16,6 +16,7 @@ module Samagotchi
16
16
  # One line each, with a few of their own fields (never text).
17
17
  ANNOUNCED = {
18
18
  turn_enqueued: %i[enqueued_id client_id],
19
+ turn_dequeued: %i[enqueued_id client_id],
19
20
  command_queued: %i[command_id client_id],
20
21
  command_ran: %i[command_id client_id status],
21
22
  input_merged: %i[count],
@@ -202,7 +202,7 @@ module Samagotchi
202
202
  parts.scripts.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
203
203
  end
204
204
 
205
- # manifest.yml for the staged bundle: requires_chi, needs and the
205
+ # manifest.yml for the staged bundle: requires_chi, needs, session_settings and the
206
206
  # file descriptions come from the installed bundle's record, the
207
207
  # trust_level from the CLI else the record.
208
208
  def write_manifest(staging, name, version, files_map, parts)
@@ -223,7 +223,8 @@ module Samagotchi
223
223
  needs: record&.needs,
224
224
  scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
225
225
  context_providers: parts.context_providers,
226
- file_descriptions: file_descriptions(record, files_map)
226
+ file_descriptions: file_descriptions(record, files_map),
227
+ session_settings: record&.session_settings || []
227
228
  )
228
229
  end
229
230
 
@@ -20,13 +20,15 @@ module Samagotchi
20
20
  # the lists as recorded (nil when absent): Manifest.parse_needs
21
21
  # and ContextProviders.parse_list check them
22
22
  # includes a profile's recorded members, nil for a plain bundle
23
+ # session_settings
24
+ # the keys a session may set (chi --set), [] when none
23
25
  # error why manifest.json couldn't be read (the rest empty), else nil
24
26
  class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
25
27
  :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
26
- :includes, :context_providers, :error)
28
+ :includes, :context_providers, :session_settings, :error)
27
29
  OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
28
30
  requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
29
- includes: nil, context_providers: nil, error: nil }.freeze
31
+ includes: nil, context_providers: nil, session_settings: [], error: nil }.freeze
30
32
 
31
33
  def initialize(name:, **fields)
32
34
  super(name: name, **OPTIONAL, **fields)
@@ -57,7 +59,8 @@ module Samagotchi
57
59
  guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
58
60
  scripts: mapping(h["scripts"]) { |meta| sha(meta) },
59
61
  plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
60
- includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil)
62
+ includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
63
+ session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [])
61
64
  end
62
65
 
63
66
  def self.string(value) = value&.to_s
@@ -420,7 +420,8 @@ module Samagotchi
420
420
  conflicts: @conflicts.keys,
421
421
  scripts_files: installed.script_files,
422
422
  context_providers: @manifest.context_providers,
423
- file_descriptions: @manifest.file_descriptions
423
+ file_descriptions: @manifest.file_descriptions,
424
+ session_settings: @manifest.session_settings
424
425
  )
425
426
  end
426
427
 
@@ -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
@@ -34,8 +34,9 @@ module Samagotchi
34
34
  # @param result [ReplyWait::Result]
35
35
  # @return [String] answered, question, failed, canceled, limit (the
36
36
  # turn ran out of iterations, nobody asked), not_continued (a Stop
37
- # answered the step-limit question), no_answer, error, worker_gone,
38
- # stopped or running
37
+ # answered the step-limit question), removed (the queued message was
38
+ # taken back before it ran), no_answer, error, worker_gone, stopped
39
+ # or running
39
40
  def status(result)
40
41
  case result.status
41
42
  when :done then "answered"
@@ -46,6 +47,7 @@ module Samagotchi
46
47
  when "canceled" then "canceled"
47
48
  when "exhausted" then "limit"
48
49
  when "not_continued" then "not_continued"
50
+ when "removed" then "removed"
49
51
  else "no_answer"
50
52
  end
51
53
  when :error then "error"
@@ -258,6 +260,7 @@ module Samagotchi
258
260
  when "canceled" then "the turn was canceled"
259
261
  when "completed" then "the turn ended with no visible answer"
260
262
  when "not_continued" then "the turn was not continued (Stop); its work so far stays"
263
+ when "removed" then "the queued message was removed before it ran; no turn ran for it"
261
264
  when "exhausted"
262
265
  "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered; " \
263
266
  "chi send #{session_id} -m '/continue yes' continues it, a message drops it"
@@ -97,8 +97,25 @@ module Samagotchi
97
97
  # @return [String] the bundle the plugin came with
98
98
  attr_reader :bundle
99
99
 
100
- # @return [Hash] config.yml `bundles: <bundle>:` (string keys), frozen
101
- 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
+ values = session_values(id) if id
115
+ merged = id ? overlay(@settings, values || {}) : @settings
116
+ @session_settings = [id, merged].freeze unless id && values.nil?
117
+ merged
118
+ end
102
119
 
103
120
  # @return [Logger]
104
121
  attr_reader :log
@@ -419,6 +436,23 @@ module Samagotchi
419
436
  nil
420
437
  end
421
438
 
439
+ # The session's own values for this bundle ({} without any; nil when
440
+ # the Host couldn't tell them, so #settings can retry).
441
+ def session_values(id)
442
+ values = @host.setup&.call&.bundle_settings
443
+ values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
444
+ rescue StandardError => e
445
+ unless (@session_settings_unread ||= {}).key?(id)
446
+ @session_settings_unread[id] = true
447
+ @log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
448
+ end
449
+ nil
450
+ end
451
+
452
+ def overlay(config, values)
453
+ values.empty? ? config : deep_freeze(config.merge(values))
454
+ end
455
+
422
456
  def deep_freeze(value)
423
457
  case value
424
458
  when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
@@ -35,7 +35,9 @@ module Samagotchi
35
35
  # reply (:no_reply): "failed", "canceled", "completed" (empty),
36
36
  # "exhausted" (it ran out of iterations, with nobody asked whether to
37
37
  # continue) or "not_continued" (a Stop answered the step-limit
38
- # question; no turn ran), from the session's last_turn; nil when unknown. With
38
+ # question; no turn ran), from the session's last_turn, or "removed"
39
+ # (turn:'s queued input was taken back before it ran, its tombstone:
40
+ # SessionInbox.removed?; no turn ran); nil when unknown. With
39
41
  # "failed", text is the failure's summary when the turn note has one.
40
42
  # @!attribute limit [Integer, nil] the iteration limit an "exhausted"
41
43
  # turn ran out at
@@ -132,6 +134,11 @@ module Samagotchi
132
134
  claimed = waiting.nil?
133
135
  end
134
136
  session = Session.load(id, state_dir: state_dir)
137
+ # Taken back before it ran (POST turn/dequeue): no turn will come.
138
+ if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
139
+ return Result.new(status: :no_reply, outcome: "removed", session: session)
140
+ end
141
+
135
142
  if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
136
143
  seen_own = true
137
144
  own_base = session.last_turn&.ended_at