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
@@ -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,13 +1,15 @@
1
1
  ---
2
2
  name: core
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.60.0"
7
+ # Its members' highest floor (cost-cap's)
8
+ requires_chi: ">= 0.83.0"
8
9
  includes:
9
10
  - loop-guard
10
11
  - check-in
11
12
  - guardrails
12
13
  - slow-commands
13
14
  - agents-md
15
+ - cost-cap
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: cost-cap
3
+ version: 0.1.1
4
+ scope: system
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
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:2ea7f76af1ea1c1b0a9a8b0b4b11d6e85a4a816fae224ef5b220cfd20fb48f42
10
+ # ctx.cost is new in chi 0.83.0
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]
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The cost-cap bundle (docs/plugins.md, The cost-cap bundle): a per-session
4
+ # cap on what the session spends (ctx.cost: reported + estimated, side
5
+ # requests included). Before each generation it compares the spend with
6
+ # the cap: at warn_at of it the user (only) gets one notice, at the cap the
7
+ # turn pauses on a question, Continue (the cap goes up by a step) or Stop
8
+ # (the turn stops, with a card). With no one to ask it stops. The model
9
+ # never sees any of it: no tool, no steer, no prompt text.
10
+ #
11
+ # Settings (config.yml, bundles: cost-cap:), read at each use from
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
15
+ # default: 5.00 USD cap for a session with none set; absent/0/off: none
16
+ # child: 2.00 for delegate children (ctx.delegate?); absent: default
17
+ # warn_at: 0.8 the share of the cap that warns; 0/off: no warning
18
+ #
19
+ # State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
20
+ # written by /cap and by Continue; "cap": null (or false) is off for the
21
+ # session, over the config. Without a file the cap comes from the settings:
22
+ # cap:, else child: for a delegate, else default:.
23
+ # The cap is resolved once per session id (ctx.delegate? parses the session
24
+ # file) and kept until /cap or Continue writes a new one.
25
+ require "json"
26
+ require "fileutils"
27
+
28
+ class Plugin
29
+ USAGE = "usage: /cap [N | $N | off] (USD; /cap alone shows the spend)"
30
+ OPTIONS = %w[Continue Stop].freeze
31
+ WARN_AT = 0.8
32
+
33
+ def initialize(_settings)
34
+ @mutex = Mutex.new
35
+ @resolved = nil # {session:, cap:, step:}
36
+ @warned = nil # [session id, cap] the share warning was given for
37
+ @unknown_warned = nil # the session id the "can't be enforced" notice was given for
38
+ end
39
+
40
+ def register(chi)
41
+ chi.on(:before_generation) { |_event, ctx| check(ctx) }
42
+ # Only the "can't be enforced" notice: a turn of one generation (or a
43
+ # session's last) has no next before_generation to give it.
44
+ chi.on(:after_generation) { |_event, ctx| check_known(ctx) }
45
+ chi.command("/cap", "see or set this session's cost cap: /cap, /cap 5, /cap off", anytime: true) do |args, ctx|
46
+ command(args, ctx)
47
+ end
48
+ end
49
+
50
+ # The cap in USD (a positive Float) or nil (none, 0, off, false).
51
+ def self.amount(value)
52
+ number = case value
53
+ when Numeric then value.to_f
54
+ when String then value.strip.delete_prefix("$").then { |s| s.match?(/\A\d+(\.\d+)?\z/) ? s.to_f : nil }
55
+ end
56
+ number&.positive? ? number : nil
57
+ end
58
+
59
+ # Cents, or up to four decimals under $0.10, so a small cap (/cap 0.003)
60
+ # doesn't read "$0.00 spent of $0.00".
61
+ def self.money(value)
62
+ return format("$%.2f", value) if value.zero? || value.abs >= 0.1
63
+
64
+ format("$%.4f", value).sub(/(\.\d\d\d*?)0+\z/, '\1')
65
+ end
66
+
67
+ private
68
+
69
+ def check(ctx)
70
+ resolved = resolve(ctx)
71
+ cap = resolved[:cap]
72
+ return unless cap
73
+
74
+ cost = ctx.cost
75
+ return unenforceable(ctx, resolved[:session], cap) unless cost.known?
76
+
77
+ total = cost.total
78
+ return at_cap(ctx, resolved, cost) if total >= cap
79
+
80
+ share = warn_share(ctx)
81
+ return unless share && total >= share * cap
82
+ return unless first_time?(:@warned, [resolved[:session], cap])
83
+
84
+ ctx.notify("#{money(total)} of #{money(cap)} spent (#{(total / cap * 100).floor}%)", level: :warn)
85
+ end
86
+
87
+ def check_known(ctx)
88
+ resolved = resolve(ctx)
89
+ return unless resolved[:cap]
90
+
91
+ unenforceable(ctx, resolved[:session], resolved[:cap]) unless ctx.cost.known?
92
+ end
93
+
94
+ def unenforceable(ctx, session, cap)
95
+ return unless first_time?(:@unknown_warned, session)
96
+
97
+ ctx.notify("the #{money(cap)} cap can't be enforced: #{ctx.model || "the model"} reports no cost and has no price",
98
+ level: :warn)
99
+ end
100
+
101
+ def at_cap(ctx, resolved, cost)
102
+ cap = resolved[:cap]
103
+ raised = raised_cap(cap, resolved[:step] || cap, cost.total)
104
+ answer = ctx.ask_user(question: "Cost cap reached: #{money(cost.total)} spent of #{money(cap)}. " \
105
+ "Continue raises it to #{money(raised)}.",
106
+ header: "Cost cap", options: OPTIONS)
107
+ if continue?(answer)
108
+ save(ctx, resolved[:session], "cap" => raised, "step" => resolved[:step] || cap)
109
+ ctx.notify("cap raised to #{money(raised)}")
110
+ else
111
+ stop(ctx, cap, cost)
112
+ end
113
+ end
114
+
115
+ # Another step on the cap, as many steps as it takes to pass the spend
116
+ # (a turn that overshot by more than a step asks once, not each step).
117
+ def raised_cap(cap, step, total)
118
+ raised = cap + step
119
+ raised += step while raised <= total
120
+ raised.round(6)
121
+ end
122
+
123
+ def continue?(answer)
124
+ return false unless answer
125
+
126
+ Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
127
+ end
128
+
129
+ def stop(ctx, cap, cost)
130
+ return unless ctx.stop_turn("cost cap reached (#{money(cost.total)} of #{money(cap)})")
131
+
132
+ ctx.card(title: "cost-cap stopped the turn",
133
+ body: "Spent #{breakdown(cost)} of a #{money(cap)} cap.\n\n`/cap N` raises it, `/cap off` removes it.",
134
+ level: :warn)
135
+ end
136
+
137
+ def command(args, ctx)
138
+ arg = args.to_s.strip.downcase
139
+ return show(ctx) if arg.empty?
140
+
141
+ session = ctx.session_id
142
+ return "cost-cap: no session yet" unless session
143
+
144
+ if %w[off 0].include?(arg)
145
+ save(ctx, session, "cap" => nil)
146
+ return "cost-cap: no cap for this session"
147
+ end
148
+ value = arg.match?(/\A\$?\d+(\.\d+)?\z/) ? self.class.amount(arg) : nil
149
+ return USAGE unless value
150
+
151
+ save(ctx, session, "cap" => value, "step" => value)
152
+ "cost-cap: cap set to #{money(value)} (#{money(ctx.cost.total)} spent)"
153
+ rescue StandardError => e
154
+ "cost-cap: #{e.message}"
155
+ end
156
+
157
+ def show(ctx)
158
+ cost = ctx.cost
159
+ cap = resolve(ctx)[:cap]
160
+ return "cost-cap: no cap (#{money(cost.total)} spent)" unless cap
161
+
162
+ line = "cost-cap: #{money(cost.total)} spent of #{money(cap)} (#{parts(cost)})"
163
+ cost.known? ? line : "#{line} (can't be enforced: no cost reported or priced)"
164
+ end
165
+
166
+ def parts(cost) = "reported #{money(cost.reported)}, estimated #{money(cost.estimated)}, side #{money(cost.side)}"
167
+
168
+ def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
169
+
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.
173
+ def resolve(ctx)
174
+ session = ctx.session_id
175
+ cached = @mutex.synchronize { @resolved }
176
+ return cached if cached && cached[:session] == session
177
+
178
+ resolved = { session: session, **(state(ctx, session) || configured(ctx)) }
179
+ @mutex.synchronize { @resolved = resolved }
180
+ end
181
+
182
+ def state(ctx, session)
183
+ return nil unless session
184
+
185
+ saved = JSON.parse(File.read(state_path(ctx, session)))
186
+ return nil unless saved.is_a?(Hash) && saved.key?("cap")
187
+
188
+ cap = self.class.amount(saved["cap"])
189
+ { cap: cap, step: self.class.amount(saved["step"]) || cap }
190
+ rescue Errno::ENOENT, JSON::ParserError
191
+ nil
192
+ end
193
+
194
+ def configured(ctx)
195
+ settings = ctx.settings
196
+ return { cap: nil, step: nil } unless %w[cap default child].any? { |key| settings.key?(key) }
197
+
198
+ key = if settings.key?("cap") then "cap"
199
+ elsif settings.key?("child") && ctx.delegate? then "child"
200
+ else "default"
201
+ end
202
+ cap = self.class.amount(settings[key])
203
+ { cap: cap, step: cap }
204
+ end
205
+
206
+ def warn_share(ctx)
207
+ settings = ctx.settings
208
+ return WARN_AT unless settings.key?("warn_at")
209
+
210
+ share = settings["warn_at"]
211
+ share.is_a?(Numeric) && share.positive? ? share : nil
212
+ end
213
+
214
+ def save(ctx, session, data)
215
+ path = state_path(ctx, session)
216
+ FileUtils.mkdir_p(File.dirname(path))
217
+ Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
218
+ cap = self.class.amount(data["cap"])
219
+ @mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
220
+ end
221
+
222
+ def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
223
+
224
+ def money(value) = self.class.money(value)
225
+
226
+ # Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
227
+ def first_time?(ivar, key)
228
+ @mutex.synchronize do
229
+ next false if instance_variable_get(ivar) == key
230
+
231
+ instance_variable_set(ivar, key)
232
+ true
233
+ end
234
+ end
235
+ end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.82.0
3
+ version: 0.84.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:
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
4
5
  require_relative "client_id"
5
6
  require_relative "session"
6
7
  require_relative "session_inbox"
@@ -129,7 +130,7 @@ module Samagotchi
129
130
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
130
131
  # and the cursor it was read from (a commit after the model moved it
131
132
  # itself, with delegate_result, leaves it alone).
132
- Report = Data.define(:child_id, :text, :before, :after, :rings) do
133
+ Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
133
134
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
134
135
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
135
136
  end
@@ -142,6 +143,7 @@ module Samagotchi
142
143
  @state_dir = state_dir
143
144
  @session_dir = Session.session_dir(session_id, state_dir: state_dir)
144
145
  @predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
146
+ Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
145
147
  # child id → the Report this turn took for it (rings merged).
146
148
  @taken = {}
147
149
  end
@@ -177,12 +179,16 @@ module Samagotchi
177
179
  end
178
180
 
179
181
  # The turn that took the reports was kept: move each child's cursor on
180
- # (unless something else moved it since: the model's own
181
- # delegate_result), then delete the rings.
182
+ # and delete the rings.
182
183
  def commit
183
184
  @taken.each_value do |report|
184
185
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
185
- cursor == report.before ? report.after : cursor
186
+ next cursor unless cursor.report_claim&.fetch("id", nil) == report.claim_id
187
+
188
+ before = report.before.with(report_claim: nil)
189
+ after = report.after.with(report_claim: nil)
190
+ current = cursor.with(report_claim: nil)
191
+ current == before ? after : current
186
192
  end
187
193
  delete_rings(report.rings)
188
194
  rescue StandardError => e
@@ -193,6 +199,11 @@ module Samagotchi
193
199
 
194
200
  # The turn was rolled back: its rings stay for the next one.
195
201
  def release
202
+ @taken.each_value do |report|
203
+ Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
204
+ cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
205
+ end
206
+ end
196
207
  @taken = {}
197
208
  end
198
209
 
@@ -237,13 +248,19 @@ module Samagotchi
237
248
  timeout: 0, turn: before.turn)
238
249
  return nil unless reportable?(wait)
239
250
 
240
- # A reported end is the follow-up turn's too (DelegateCursor#turn):
241
- # a later wait on it would repeat its reply.
251
+ claim_id = before.report_claim&.fetch("id", nil) || SecureRandom.hex(16)
242
252
  after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
243
- turn: wait.status == :waiting_for_answer ? before.turn : nil)
253
+ turn: wait.status == :waiting_for_answer ? before.turn : nil, report_claim: nil)
244
254
  .with_baseline(ReplyWait.baseline_of(wait.session))
255
+ claim = { "id" => claim_id, "file" => wait.file, "cursor" => after.to_json_hash }
256
+ return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
257
+ state_dir: @state_dir)
258
+
259
+ # A reported end is the follow-up turn's too (DelegateCursor#turn):
260
+ # a later wait on it would repeat its reply.
261
+ after = after.with(report_claim: claim)
245
262
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
246
- Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
263
+ Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
247
264
  rescue ArgumentError
248
265
  nil # the child is gone
249
266
  end
@@ -2581,7 +2581,8 @@ module Samagotchi
2581
2581
  # build and after a rebuild that drops them (SystemPrompt#prompt_files
2582
2582
  # returns [] itself: the || keeps a nil builder safe).
2583
2583
  prompt_files: -> { @prompt_builder&.prompt_files || [] },
2584
- frontend: -> { interface }
2584
+ frontend: -> { interface },
2585
+ cost: -> { @metrics.cost }
2585
2586
  )
2586
2587
  end
2587
2588
 
@@ -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