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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +2 -2
- data/bin/chi +27 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +52 -9
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +85 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +13 -1
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/bundle_session_settings.rb +212 -0
- data/lib/samagotchi/bundles/core/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +13 -0
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +235 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +25 -8
- data/lib/samagotchi/engine.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +2 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +40 -3
- data/lib/samagotchi/reply_wait.rb +13 -15
- data/lib/samagotchi/send_command.rb +24 -7
- data/lib/samagotchi/session.rb +16 -1
- data/lib/samagotchi/session_metrics/cost.rb +37 -0
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/session_setup.rb +14 -6
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +7 -2
- data/lib/samagotchi/testing/bundle.rb +31 -5
- data/lib/samagotchi/testing/host.rb +26 -1
- data/lib/samagotchi/testing/recorder.rb +4 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
- data/lib/samagotchi/tools/delegate_wait.rb +40 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +13 -4
- 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.
|
|
3
|
+
version: 0.3.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Recommended safety set"
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
|
|
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,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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|