samagotchi 0.99.0 → 0.100.1
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 +36 -1
- data/README.md +2 -2
- data/docs/architecture.md +9 -3
- data/docs/bundle-authoring.md +1 -1
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +6 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +67 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +7 -2
- data/lib/samagotchi/bundles/core/manifest.yml +4 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +13 -0
- data/lib/samagotchi/bundles/work-cap/plugin.rb +401 -0
- data/lib/samagotchi/engine.rb +2 -1
- data/lib/samagotchi/hooks/loader.rb +34 -6
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +10 -2
- data/lib/samagotchi/session_metrics/usage.rb +31 -0
- data/lib/samagotchi/session_metrics.rb +48 -5
- data/lib/samagotchi/testing/host.rb +30 -1
- data/lib/samagotchi/testing/recorder.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +44 -8
- data/lib/samagotchi/web/public/chains.js +24 -0
- data/lib/samagotchi/web/public/index.html +7 -1
- data/lib/samagotchi/web/public/markdown_place.js +47 -1
- data/lib/samagotchi/web/public/send_turn.js +6 -2
- data/lib/samagotchi/web/public/session_actions.js +10 -5
- data/lib/samagotchi/web/public/turn_view.js +21 -7
- metadata +4 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The work-cap bundle (docs/plugins.md, The work-cap bundle): a per-session
|
|
4
|
+
# cap on the work a session does across its turns (ctx.usage: tool calls,
|
|
5
|
+
# active minutes), the session-wide bound the per-turn step limit and
|
|
6
|
+
# check-in don't give. Before each generation it compares the work with the
|
|
7
|
+
# caps: at warn_at of a cap the user (only) gets one notice, at a cap the
|
|
8
|
+
# turn pauses on a question, Continue (the cap goes up by a step) or Stop
|
|
9
|
+
# (the turn stops, with a card). With no one to ask it stops. The question
|
|
10
|
+
# names the session's totals, so a parent agent answering it decides on the
|
|
11
|
+
# whole picture. The model never sees any of it: no tool, no steer, no
|
|
12
|
+
# prompt text. cost-cap (the $ cap) is its sibling; the wordings follow it.
|
|
13
|
+
#
|
|
14
|
+
# Settings (config.yml, bundles: work-cap:), from ctx.settings, never at
|
|
15
|
+
# load: the caps are resolved once per session and settings (see State
|
|
16
|
+
# below), again when ctx.settings changes, so a config change reaches a
|
|
17
|
+
# running session (the state file still wins per meter); warn_at and
|
|
18
|
+
# ignore_tools are read at each check.
|
|
19
|
+
# tool_calls: 400 this session's own tool-call cap (session_settings:,
|
|
20
|
+
# chi --set work-cap.tool_calls=400); 0/off: none
|
|
21
|
+
# minutes: 60 this session's own cap on active minutes, same rules
|
|
22
|
+
# child: {tool_calls: 500, minutes: 90} delegates (ctx.delegate?) with
|
|
23
|
+
# no own value
|
|
24
|
+
# default: {} every other session (and a delegate without child:)
|
|
25
|
+
# warn_at: 0.8 the share of a cap that warns; 0/off: no warning
|
|
26
|
+
# ignore_tools: [task_wait, task_get, delegate, delegate_result]
|
|
27
|
+
# tools whose calls and time aren't counted (waiting:
|
|
28
|
+
# delegate blocks on its child by default)
|
|
29
|
+
# Each meter resolves on its own: its own value, else child:'s for a
|
|
30
|
+
# delegate, else default:'s. An empty key (YAML nil) is unset and falls
|
|
31
|
+
# through; off, false and 0 are an explicit "no cap". A value that doesn't
|
|
32
|
+
# read (tool_calls: many) warns the user once and the next one is used,
|
|
33
|
+
# never a silent "no cap".
|
|
34
|
+
#
|
|
35
|
+
# What counts: ctx.usage's tool calls and active time (generations plus
|
|
36
|
+
# finished tool calls, never a question's wait), less ignore_tools'.
|
|
37
|
+
#
|
|
38
|
+
# State: <data_dir>/sessions/<session id>.json, {"tool_calls": {"cap": 800,
|
|
39
|
+
# "step": 400}, "minutes": {"cap": null}}, written by /workcap and by
|
|
40
|
+
# Continue; a meter's "cap": null is off for the session, over the config;
|
|
41
|
+
# a meter missing from the file comes from the settings. Resolved once per
|
|
42
|
+
# session id and settings (ctx.delegate? parses the session file) and kept
|
|
43
|
+
# until /workcap or Continue writes a new file, or the settings change.
|
|
44
|
+
require "json"
|
|
45
|
+
require "fileutils"
|
|
46
|
+
|
|
47
|
+
class Plugin
|
|
48
|
+
USAGE = "usage: /workcap [N | Nm | Nh | off] (N tool calls, Nm or Nh active time; /workcap alone shows the totals)"
|
|
49
|
+
OPTIONS = %w[Continue Stop].freeze
|
|
50
|
+
# For a parent agent's answer (guardrails.parent_answers): Continue
|
|
51
|
+
# spends more, Stop denies; under hard a parent may only Stop.
|
|
52
|
+
EFFECTS = { "Continue" => :spend, "Stop" => :deny }.freeze
|
|
53
|
+
WARN_AT = 0.8
|
|
54
|
+
METERS = %w[tool_calls minutes].freeze
|
|
55
|
+
DEFAULT_IGNORE = %w[task_wait task_get delegate delegate_result].freeze
|
|
56
|
+
# A cap as text: 3, 2.5, .5, 1e1, -1 (a negative is none).
|
|
57
|
+
NUMBER = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?\z/i
|
|
58
|
+
# Minutes as text: a NUMBER, or one with m/min (minutes) or h (hours).
|
|
59
|
+
DURATION = /\A(?<number>.+?)\s*(?<unit>m|min|mins|minutes|h|hours?)?\z/i
|
|
60
|
+
|
|
61
|
+
def initialize(_settings)
|
|
62
|
+
@mutex = Mutex.new
|
|
63
|
+
@resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
|
|
64
|
+
@warned = {} # meter => [session id, cap] the share warning was given for
|
|
65
|
+
@unread_warned = nil # the session id the "isn't a number" notice was given for
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def register(chi)
|
|
69
|
+
chi.on(:before_generation) { |_event, ctx| check(ctx) }
|
|
70
|
+
chi.command("/workcap", "see or set this session's work cap: /workcap, /workcap 400, /workcap 90m, /workcap off",
|
|
71
|
+
anytime: true) do |args, ctx|
|
|
72
|
+
command(args, ctx)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A cap (a positive Float) or nil (none, 0, off, false, or a value that
|
|
77
|
+
# isn't a number: .readable? tells them apart). Minutes take a unit:
|
|
78
|
+
# 90m, 1.5h.
|
|
79
|
+
def self.amount(value, meter)
|
|
80
|
+
number = number(value, meter)
|
|
81
|
+
number&.positive? ? number : nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# +value+ as a finite Float, else nil. "5." gets its 0 first
|
|
85
|
+
# (.float_text: Ruby 3.3's Float wants one).
|
|
86
|
+
def self.number(value, meter)
|
|
87
|
+
number = case value
|
|
88
|
+
when Numeric then value.to_f
|
|
89
|
+
when String then text_number(value.strip, meter)
|
|
90
|
+
end
|
|
91
|
+
number if number&.finite?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.text_number(text, meter)
|
|
95
|
+
return Float(float_text(text)) if text.match?(NUMBER)
|
|
96
|
+
return nil unless meter == "minutes" && (match = DURATION.match(text)) && match[:unit]
|
|
97
|
+
return nil unless match[:number].match?(NUMBER)
|
|
98
|
+
|
|
99
|
+
Float(float_text(match[:number])) * (match[:unit].downcase.start_with?("h") ? 60 : 1)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A NUMBER as Float reads it on every Ruby: a bare point ("5.", "5.e1")
|
|
103
|
+
# gets a 0 after it.
|
|
104
|
+
def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
|
|
105
|
+
|
|
106
|
+
# Whether +value+ reads as a cap or as none: a number (text too), off,
|
|
107
|
+
# false or nil (nil is unset, not none: #first_readable skips it); not
|
|
108
|
+
# "many", true or a list.
|
|
109
|
+
def self.readable?(value, meter)
|
|
110
|
+
case value
|
|
111
|
+
when false, nil then true
|
|
112
|
+
when Numeric then value.finite?
|
|
113
|
+
when String then value.strip.casecmp?("off") || !number(value, meter).nil?
|
|
114
|
+
else false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 400, 2.5: a whole number without its ".0".
|
|
119
|
+
def self.count(value)
|
|
120
|
+
rounded = value.round(1)
|
|
121
|
+
rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def check(ctx)
|
|
127
|
+
resolved = resolve(ctx)
|
|
128
|
+
unreadable(ctx, resolved)
|
|
129
|
+
capped = resolved[:meters].select { |_meter, meter_cap| meter_cap[:cap] }
|
|
130
|
+
return if capped.empty?
|
|
131
|
+
|
|
132
|
+
usage = ctx.usage
|
|
133
|
+
used = used(ctx, usage)
|
|
134
|
+
reached = capped.select { |meter, meter_cap| used[meter] >= meter_cap[:cap] }
|
|
135
|
+
return at_cap(ctx, resolved, reached, usage, used) unless reached.empty?
|
|
136
|
+
|
|
137
|
+
share = warn_share(ctx)
|
|
138
|
+
return unless share
|
|
139
|
+
|
|
140
|
+
capped.each do |meter, meter_cap|
|
|
141
|
+
cap = meter_cap[:cap]
|
|
142
|
+
next unless used[meter] >= share * cap && first_warning?(meter, [resolved[:session], cap])
|
|
143
|
+
|
|
144
|
+
ctx.notify("#{count(used[meter])} of #{count(cap)} #{unit(meter)} (#{(used[meter] / cap * 100).floor}%)",
|
|
145
|
+
level: :warn)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The counted work per meter: tool calls and active minutes, less the
|
|
150
|
+
# ignored tools' share.
|
|
151
|
+
def used(ctx, usage)
|
|
152
|
+
ignored = ignore_tools(ctx)
|
|
153
|
+
calls = usage.tool_calls - ignored.sum { |tool| usage.tool_calls_by_tool.fetch(tool, 0) }
|
|
154
|
+
active_ms = usage.active_ms - ignored.sum { |tool| usage.tool_ms_by_tool.fetch(tool, 0) }
|
|
155
|
+
{ "tool_calls" => [calls, 0].max.to_f, "minutes" => [active_ms, 0].max / 60_000.0 }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Once per session: the settings whose cap didn't read, and what is used.
|
|
159
|
+
def unreadable(ctx, resolved)
|
|
160
|
+
bad = resolved[:unreadable]
|
|
161
|
+
return if bad.empty? || !first_time?(:@unread_warned, resolved[:session])
|
|
162
|
+
|
|
163
|
+
named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
|
|
164
|
+
used = resolved[:meters].values.filter_map do |meter_cap|
|
|
165
|
+
"#{meter_cap[:key]}: (#{count(meter_cap[:cap])})" if meter_cap[:cap]
|
|
166
|
+
end
|
|
167
|
+
using = used.empty? ? "no cap" : "using #{used.join(", ")}"
|
|
168
|
+
ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using}", level: :warn)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def at_cap(ctx, resolved, reached, usage, used)
|
|
172
|
+
raised = reached.to_h do |meter, meter_cap|
|
|
173
|
+
[meter, raised_cap(meter_cap[:cap], meter_cap[:step] || meter_cap[:cap], used[meter])]
|
|
174
|
+
end
|
|
175
|
+
answer = ctx.ask_user(question: "Work cap reached: #{reached_text(reached, used)} (#{totals(ctx, usage, used, reached.keys)}). " \
|
|
176
|
+
"Continue raises #{raised_text(raised)}.",
|
|
177
|
+
header: "Work cap", options: OPTIONS, effects: EFFECTS)
|
|
178
|
+
if continue?(answer)
|
|
179
|
+
raised.each do |meter, cap|
|
|
180
|
+
save_meter(ctx, resolved[:session], meter, "cap" => cap, "step" => reached[meter][:step] || reached[meter][:cap])
|
|
181
|
+
end
|
|
182
|
+
ctx.notify("#{raised.size == 1 ? "cap" : "caps"} raised to " \
|
|
183
|
+
"#{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}")
|
|
184
|
+
else
|
|
185
|
+
stop(ctx, reached, usage, used)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# "412 tool calls of 400", and "61 active minutes of 60" for the other.
|
|
190
|
+
def reached_text(reached, used)
|
|
191
|
+
reached.map { |meter, meter_cap| "#{count(used[meter])} #{unit(meter)} of #{count(meter_cap[:cap])}" }.join(", ")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# "it to 800", or "them to 800 tool calls and 120 active minutes".
|
|
195
|
+
def raised_text(raised)
|
|
196
|
+
return "it to #{count(raised.values.first)}" if raised.size == 1
|
|
197
|
+
|
|
198
|
+
"them to #{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The session's totals a reader decides on, the reached meters left out
|
|
202
|
+
# (already named): turns, tool calls, active minutes and the spend.
|
|
203
|
+
def totals(ctx, usage, used, skip = [])
|
|
204
|
+
parts = ["#{usage.turns} #{usage.turns == 1 ? "turn" : "turns"}"]
|
|
205
|
+
parts << "#{count(used["tool_calls"])} tool calls" unless skip.include?("tool_calls")
|
|
206
|
+
parts << "#{active_text(used["minutes"])} active" unless skip.include?("minutes")
|
|
207
|
+
cost = ctx.cost
|
|
208
|
+
parts << format("$%.2f", cost.total) if cost.known? && cost.total.positive?
|
|
209
|
+
parts.join(", ")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# "47 min", or "<1 min" before a whole minute of work.
|
|
213
|
+
def active_text(minutes) = minutes < 1 ? "<1 min" : "#{count(minutes.floor)} min"
|
|
214
|
+
|
|
215
|
+
# Another step on the cap, as many steps as it takes to pass the work
|
|
216
|
+
# (a turn that overshot by more than a step asks once, not each step):
|
|
217
|
+
# Continue always leaves the cap past the work now.
|
|
218
|
+
def raised_cap(cap, step, used)
|
|
219
|
+
raised = cap + step
|
|
220
|
+
raised += step while raised <= used
|
|
221
|
+
raised.round(6)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def continue?(answer)
|
|
225
|
+
return false unless answer
|
|
226
|
+
|
|
227
|
+
Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def stop(ctx, reached, usage, used)
|
|
231
|
+
return unless ctx.stop_turn("work cap reached (#{reached_text(reached, used)})")
|
|
232
|
+
|
|
233
|
+
ctx.card(title: "work-cap stopped the turn",
|
|
234
|
+
body: "Used #{reached_text(reached, used)} (#{totals(ctx, usage, used, reached.keys)}).\n\n" \
|
|
235
|
+
"`/workcap N` raises the tool-call cap, `/workcap Nm` the active minutes, `/workcap off` removes both.",
|
|
236
|
+
level: :warn)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def command(args, ctx)
|
|
240
|
+
arg = args.to_s.strip.downcase
|
|
241
|
+
return show(ctx) if arg.empty?
|
|
242
|
+
|
|
243
|
+
session = ctx.session_id
|
|
244
|
+
return "work-cap: no session yet" unless session
|
|
245
|
+
|
|
246
|
+
if %w[off 0].include?(arg)
|
|
247
|
+
METERS.each { |meter| save_meter(ctx, session, meter, "cap" => nil) }
|
|
248
|
+
return "work-cap: no cap for this session"
|
|
249
|
+
end
|
|
250
|
+
meter = arg.match?(NUMBER) ? "tool_calls" : "minutes"
|
|
251
|
+
value = self.class.amount(arg, meter)
|
|
252
|
+
return USAGE unless value
|
|
253
|
+
|
|
254
|
+
save_meter(ctx, session, meter, "cap" => value, "step" => value)
|
|
255
|
+
"work-cap: #{unit(meter)} cap set to #{count(value)} (#{count(used(ctx, ctx.usage)[meter])} so far)"
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
ctx.log.warn(:workcap_command_failed, error: "#{e.class}: #{e.message}")
|
|
258
|
+
"work-cap: #{e.message}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def show(ctx)
|
|
262
|
+
usage = ctx.usage
|
|
263
|
+
used = used(ctx, usage)
|
|
264
|
+
meters = resolve(ctx)[:meters]
|
|
265
|
+
return "work-cap: no cap (#{totals(ctx, usage, used)})" if meters.values.none? { |meter_cap| meter_cap[:cap] }
|
|
266
|
+
|
|
267
|
+
capped = meters.select { |_meter, meter_cap| meter_cap[:cap] }
|
|
268
|
+
caps = capped.map { |meter, meter_cap| "#{count(used[meter])} of #{count(meter_cap[:cap])} #{unit(meter)}" }
|
|
269
|
+
"work-cap: #{caps.join(", ")} (#{totals(ctx, usage, used, capped.keys)})"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def unit(meter) = meter == "tool_calls" ? "tool calls" : "active minutes"
|
|
273
|
+
|
|
274
|
+
# The session's caps now: per meter the state file's, else the session's
|
|
275
|
+
# own value (--set), else child:'s for a delegate, else default:'s.
|
|
276
|
+
# Cached per session id and settings (a config change resolves again).
|
|
277
|
+
def resolve(ctx)
|
|
278
|
+
session = ctx.session_id
|
|
279
|
+
settings = ctx.settings
|
|
280
|
+
cached = @mutex.synchronize { @resolved }
|
|
281
|
+
return cached if cached && cached[:session] == session && cached[:settings] == settings
|
|
282
|
+
|
|
283
|
+
saved = state(ctx, session)
|
|
284
|
+
configured = configured(ctx)
|
|
285
|
+
meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
|
|
286
|
+
resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable] }
|
|
287
|
+
@mutex.synchronize { @resolved = resolved }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The state file's meters ({meter => {cap:, step:}}), {} without one.
|
|
291
|
+
def state(ctx, session)
|
|
292
|
+
return {} unless session
|
|
293
|
+
|
|
294
|
+
saved = JSON.parse(File.read(state_path(ctx, session)))
|
|
295
|
+
return {} unless saved.is_a?(Hash)
|
|
296
|
+
|
|
297
|
+
METERS.each_with_object({}) do |meter, meters|
|
|
298
|
+
entry = saved[meter]
|
|
299
|
+
next unless entry.is_a?(Hash) && entry.key?("cap")
|
|
300
|
+
|
|
301
|
+
cap = self.class.amount(entry["cap"], meter)
|
|
302
|
+
meters[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
|
|
303
|
+
end
|
|
304
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
305
|
+
{}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Per meter the first of its own value, child:'s (a delegate's) and
|
|
309
|
+
# default:'s that is set (an empty key, nil, isn't) and reads
|
|
310
|
+
# (.readable?); the ones set that don't read (and a child:/default: that
|
|
311
|
+
# isn't a mapping) are kept for the notice (#unreadable).
|
|
312
|
+
def configured(ctx)
|
|
313
|
+
settings = ctx.settings
|
|
314
|
+
unreadable = {}
|
|
315
|
+
groups = %w[child default].select { |group| settings.key?(group) }
|
|
316
|
+
groups.delete("child") if groups.include?("child") && !ctx.delegate?
|
|
317
|
+
groups = groups.select do |group|
|
|
318
|
+
next true if settings[group].is_a?(Hash)
|
|
319
|
+
|
|
320
|
+
unreadable[group] = settings[group] unless settings[group].nil?
|
|
321
|
+
false
|
|
322
|
+
end
|
|
323
|
+
meters = METERS.to_h do |meter|
|
|
324
|
+
sources = [[meter, settings, meter]] + groups.map { |group| ["#{group}.#{meter}", settings[group], meter] }
|
|
325
|
+
[meter, first_readable(sources, unreadable)]
|
|
326
|
+
end
|
|
327
|
+
{ meters: meters, unreadable: unreadable }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def first_readable(sources, unreadable)
|
|
331
|
+
sources.each do |name, values, meter|
|
|
332
|
+
next unless values.key?(meter)
|
|
333
|
+
|
|
334
|
+
value = values[meter]
|
|
335
|
+
next if value.nil?
|
|
336
|
+
|
|
337
|
+
unless self.class.readable?(value, meter)
|
|
338
|
+
unreadable[name] = value
|
|
339
|
+
next
|
|
340
|
+
end
|
|
341
|
+
cap = self.class.amount(value, meter)
|
|
342
|
+
return { cap: cap, step: cap, key: name }
|
|
343
|
+
end
|
|
344
|
+
{ cap: nil, step: nil, key: nil }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def ignore_tools(ctx)
|
|
348
|
+
settings = ctx.settings
|
|
349
|
+
settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def warn_share(ctx)
|
|
353
|
+
settings = ctx.settings
|
|
354
|
+
return WARN_AT unless settings.key?("warn_at")
|
|
355
|
+
|
|
356
|
+
share = settings["warn_at"]
|
|
357
|
+
share.is_a?(Numeric) && share.positive? ? share : nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Write one meter's cap into the session's state file (the other kept).
|
|
361
|
+
def save_meter(ctx, session, meter, data)
|
|
362
|
+
path = state_path(ctx, session)
|
|
363
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
364
|
+
saved = begin
|
|
365
|
+
JSON.parse(File.read(path))
|
|
366
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
367
|
+
{}
|
|
368
|
+
end
|
|
369
|
+
saved = {} unless saved.is_a?(Hash)
|
|
370
|
+
saved[meter] = data.transform_values { |value| whole(value) }
|
|
371
|
+
Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
|
|
372
|
+
# Resolved again at the next use, from the file now holding the meter.
|
|
373
|
+
@mutex.synchronize { @resolved = nil }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
|
|
377
|
+
def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
|
|
378
|
+
|
|
379
|
+
def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
|
|
380
|
+
|
|
381
|
+
def count(value) = self.class.count(value)
|
|
382
|
+
|
|
383
|
+
def first_warning?(meter, key)
|
|
384
|
+
@mutex.synchronize do
|
|
385
|
+
next false if @warned[meter] == key
|
|
386
|
+
|
|
387
|
+
@warned[meter] = key
|
|
388
|
+
true
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
|
|
393
|
+
def first_time?(ivar, key)
|
|
394
|
+
@mutex.synchronize do
|
|
395
|
+
next false if instance_variable_get(ivar) == key
|
|
396
|
+
|
|
397
|
+
instance_variable_set(ivar, key)
|
|
398
|
+
true
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -2680,7 +2680,8 @@ module Samagotchi
|
|
|
2680
2680
|
# returns [] itself: the || keeps a nil builder safe).
|
|
2681
2681
|
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2682
2682
|
frontend: -> { interface },
|
|
2683
|
-
cost: -> { @metrics.cost }
|
|
2683
|
+
cost: -> { @metrics.cost },
|
|
2684
|
+
usage: -> { @metrics.usage }
|
|
2684
2685
|
)
|
|
2685
2686
|
end
|
|
2686
2687
|
|
|
@@ -52,15 +52,21 @@ module Samagotchi
|
|
|
52
52
|
|
|
53
53
|
# Load hooks from a config hash and return a Registry with registered plugins.
|
|
54
54
|
#
|
|
55
|
-
# @param config_hash [Hash, nil] the
|
|
55
|
+
# @param config_hash [Hash, nil] the parsed config.yml
|
|
56
56
|
# @param env [Hash] environment variables (default: ENV)
|
|
57
57
|
# @param failures [Guardrails::LoadFailures, nil] collects hooks that
|
|
58
58
|
# failed to load
|
|
59
59
|
# @return [Samagotchi::Hooks::Registry] the registry with all plugins registered
|
|
60
60
|
def load(config_hash, env: ENV, failures: nil)
|
|
61
|
-
|
|
61
|
+
hooks_config = config_hash["hooks"] if config_hash.is_a?(Hash)
|
|
62
|
+
# An empty `hooks:` key is nil in YAML: no hooks, like no key.
|
|
63
|
+
return Hooks::Registry.new if hooks_config.nil?
|
|
64
|
+
|
|
65
|
+
unless hooks_config.is_a?(Hash)
|
|
66
|
+
Log.warn(:hooks, "hooks_section_invalid", echo: "[samagotchi:hooks] config.yml hooks: must be a mapping of event name to hook entries; ignored")
|
|
67
|
+
return Hooks::Registry.new
|
|
68
|
+
end
|
|
62
69
|
|
|
63
|
-
hooks_config = config_hash["hooks"]
|
|
64
70
|
hooks_dir = hooks_dir(env, data: config_hash)
|
|
65
71
|
|
|
66
72
|
registry = Hooks::Registry.new
|
|
@@ -92,13 +98,20 @@ module Samagotchi
|
|
|
92
98
|
|
|
93
99
|
# Parse hook definitions from the config hooks section.
|
|
94
100
|
# Returns an array of { event_type:, path:, on_error: } hashes.
|
|
101
|
+
# An event key that holds something other than a list warns and is
|
|
102
|
+
# skipped; an empty one (nil) is skipped silently.
|
|
95
103
|
def self.parse_definitions(hooks_config)
|
|
96
104
|
definitions = []
|
|
97
105
|
hooks_config.each do |event_type, configs|
|
|
98
|
-
next
|
|
106
|
+
next if event_type.to_s == "hooks_dir" || configs.nil?
|
|
107
|
+
|
|
108
|
+
unless configs.is_a?(Array)
|
|
109
|
+
Log.warn(:hooks, "hooks_event_invalid", echo: "[samagotchi:hooks] config.yml hooks.#{event_type}: must be a list of hook entries; ignored", event: event_type.to_s)
|
|
110
|
+
next
|
|
111
|
+
end
|
|
99
112
|
|
|
100
|
-
configs.
|
|
101
|
-
next unless
|
|
113
|
+
configs.each_with_index do |cfg, index|
|
|
114
|
+
next unless valid_entry?(event_type, cfg, index)
|
|
102
115
|
|
|
103
116
|
definitions << {
|
|
104
117
|
event_type: event_type.to_s,
|
|
@@ -112,6 +125,21 @@ module Samagotchi
|
|
|
112
125
|
definitions
|
|
113
126
|
end
|
|
114
127
|
|
|
128
|
+
# A hook entry is a mapping with a path:; anything else warns, naming
|
|
129
|
+
# the entry (hooks.EVENT[N], from 0), and is skipped alone.
|
|
130
|
+
def self.valid_entry?(event_type, cfg, index)
|
|
131
|
+
where = "hooks.#{event_type}[#{index}]"
|
|
132
|
+
unless cfg.is_a?(Hash)
|
|
133
|
+
Log.warn(:hooks, "hooks_entry_invalid", echo: "[samagotchi:hooks] config.yml #{where}: must be a mapping with a path:; ignored", event: event_type.to_s, index: index)
|
|
134
|
+
return false
|
|
135
|
+
end
|
|
136
|
+
return true if cfg["path"].is_a?(String) && !cfg["path"].strip.empty?
|
|
137
|
+
|
|
138
|
+
Log.warn(:hooks, "hooks_entry_no_path", echo: "[samagotchi:hooks] config.yml #{where}: has no path:; ignored", event: event_type.to_s, index: index)
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
private_class_method :valid_entry?
|
|
142
|
+
|
|
115
143
|
# Load a plugin from the hooks directory.
|
|
116
144
|
# Returns an instance of the plugin class. Instances are cached per
|
|
117
145
|
# [path, settings] across Engines: two entries with different
|
|
@@ -40,7 +40,8 @@ module Samagotchi
|
|
|
40
40
|
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
41
41
|
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
42
42
|
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
43
|
-
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0"
|
|
43
|
+
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
|
|
44
|
+
"ctx.usage" => "0.100.0"
|
|
44
45
|
}.freeze
|
|
45
46
|
|
|
46
47
|
# @param bundle [String]
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require_relative "../log"
|
|
5
5
|
require_relative "../paths"
|
|
6
6
|
require_relative "../session_metrics/cost"
|
|
7
|
+
require_relative "../session_metrics/usage"
|
|
7
8
|
require_relative "../guardrails/context"
|
|
8
9
|
require_relative "../hook_question"
|
|
9
10
|
require_relative "../idle_client"
|
|
@@ -55,11 +56,12 @@ module Samagotchi
|
|
|
55
56
|
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
56
57
|
# else the running turn's for work done for it (the turn's thread, or a
|
|
57
58
|
# plugin handler: +handler+) until the turn has ended.
|
|
58
|
-
# +cost+ returns the session's spend now (SessionMetrics::Cost)
|
|
59
|
+
# +cost+ returns the session's spend now (SessionMetrics::Cost),
|
|
60
|
+
# +usage+ its work now (SessionMetrics::Usage).
|
|
59
61
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
60
62
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
61
63
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
|
|
62
|
-
:cancel_controller, :cost, keyword_init: true)
|
|
64
|
+
:cancel_controller, :cost, :usage, keyword_init: true)
|
|
63
65
|
|
|
64
66
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
65
67
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -167,6 +169,12 @@ module Samagotchi
|
|
|
167
169
|
# (docs/plugins.md); zero and known with no session
|
|
168
170
|
def cost = @host.cost&.call || SessionMetrics::Cost.zero
|
|
169
171
|
|
|
172
|
+
# @return [SessionMetrics::Usage] the session's work so far, read now:
|
|
173
|
+
# #turns, #tool_calls, #tool_calls_by_tool, #steps, #active_ms
|
|
174
|
+
# (#active_minutes) and #tool_ms_by_tool (docs/plugins.md); zero
|
|
175
|
+
# with no session
|
|
176
|
+
def usage = @host.usage&.call || SessionMetrics::Usage.zero
|
|
177
|
+
|
|
170
178
|
# @return [String, nil] the name of the session's toolkit ("full" for
|
|
171
179
|
# one from before toolkits; nil with no session)
|
|
172
180
|
def toolkit = @host.toolkit&.call
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionMetrics
|
|
5
|
+
# The session's work so far (SessionMetrics#usage, what a bundle's
|
|
6
|
+
# ctx.usage reads), across its turns and workers: the same numbers
|
|
7
|
+
# /stats and `chi sessions stats` show.
|
|
8
|
+
# +turns+: finished turns plus the running one. +tool_calls+ and
|
|
9
|
+
# +tool_calls_by_tool+ (canonical names: an alias counts under the tool
|
|
10
|
+
# it resolved to, a call_tool call under the inner tool): every call,
|
|
11
|
+
# running ones included. +steps+: the loop rounds that dispatched tools
|
|
12
|
+
# (the snapshot's iterations_total). +active_ms+: work time, the
|
|
13
|
+
# generations' time plus the finished tool calls' time (approval waits
|
|
14
|
+
# left out; ask_user_question's, a wait on the user, not counted),
|
|
15
|
+
# never a question's wait between them; +tool_ms_by_tool+ is the tool
|
|
16
|
+
# part by tool (the same names), so a reader can leave a waiting tool's
|
|
17
|
+
# time (task_wait, a blocking delegate) out.
|
|
18
|
+
# Bundles use its methods only, never this constant's name.
|
|
19
|
+
Usage = Data.define(:turns, :tool_calls, :tool_calls_by_tool, :steps, :active_ms, :tool_ms_by_tool) do
|
|
20
|
+
def self.zero = new(turns: 0, tool_calls: 0, tool_calls_by_tool: {}, steps: 0, active_ms: 0, tool_ms_by_tool: {})
|
|
21
|
+
|
|
22
|
+
# Data freezes itself, not its hashes: these are copied and frozen.
|
|
23
|
+
def initialize(tool_calls_by_tool:, tool_ms_by_tool:, **)
|
|
24
|
+
super(tool_calls_by_tool: tool_calls_by_tool.dup.freeze, tool_ms_by_tool: tool_ms_by_tool.dup.freeze, **)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Float] active_ms in minutes
|
|
28
|
+
def active_minutes = active_ms / 60_000.0
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -14,6 +14,7 @@ require_relative "session"
|
|
|
14
14
|
require_relative "session_metrics/totals"
|
|
15
15
|
require_relative "session_metrics/model_segment"
|
|
16
16
|
require_relative "session_metrics/cost"
|
|
17
|
+
require_relative "session_metrics/usage"
|
|
17
18
|
require_relative "session_metrics/side_totals"
|
|
18
19
|
require_relative "side_usage"
|
|
19
20
|
|
|
@@ -53,6 +54,10 @@ module Samagotchi
|
|
|
53
54
|
SPEED_MIN_TOKENS = 8
|
|
54
55
|
SPEED_MIN_MS = 100
|
|
55
56
|
|
|
57
|
+
# The tools whose time is a wait on the user, not work (#usage).
|
|
58
|
+
USER_WAIT_TOOLS = %w[ask_user_question].freeze
|
|
59
|
+
private_constant :USER_WAIT_TOOLS
|
|
60
|
+
|
|
56
61
|
# The session's token totals as a saved analytics.json has them (zeros
|
|
57
62
|
# where an older file lacks a count), with how full the context was.
|
|
58
63
|
# A generation's decode speed (tokens per second) and where it came
|
|
@@ -377,6 +382,23 @@ module Samagotchi
|
|
|
377
382
|
@mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
|
|
378
383
|
end
|
|
379
384
|
|
|
385
|
+
# The session's work now (ctx.usage): turns, tool calls (by tool), steps
|
|
386
|
+
# and active (work) time, earlier workers' loaded records and the
|
|
387
|
+
# running turn included. Safe from any thread.
|
|
388
|
+
# @return [Usage]
|
|
389
|
+
def usage
|
|
390
|
+
@mutex.synchronize do
|
|
391
|
+
turn = @turn
|
|
392
|
+
totals = @totals.with_running(turn)
|
|
393
|
+
calls, by_tool = tool_call_counts(turn)
|
|
394
|
+
tool_ms = @tool_tallies[:ms]
|
|
395
|
+
# Finished calls only: a running call's approval wait isn't known
|
|
396
|
+
# until it completes (waited_ms), so its time counts then.
|
|
397
|
+
Usage.new(turns: turn_count(turn), tool_calls: calls, tool_calls_by_tool: by_tool, steps: totals.iterations,
|
|
398
|
+
active_ms: (totals.gen_ms + tool_ms.values.sum).round, tool_ms_by_tool: tool_ms.transform_values(&:round))
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
380
402
|
# Event sink. Safe to call from any thread; errors are isolated by the
|
|
381
403
|
# caller (SessionObserver / TerminalUI) but we still guard internally.
|
|
382
404
|
# @param event [Hash]
|
|
@@ -542,10 +564,10 @@ module Samagotchi
|
|
|
542
564
|
def build_snapshot(records_mode)
|
|
543
565
|
turn = @turn
|
|
544
566
|
totals = @totals.with_running(turn)
|
|
545
|
-
|
|
567
|
+
calls, by_tool = tool_call_counts(turn)
|
|
546
568
|
{
|
|
547
569
|
session_id: @session_id,
|
|
548
|
-
turns:
|
|
570
|
+
turns: turn_count(turn),
|
|
549
571
|
cancellations: totals.cancellations,
|
|
550
572
|
tokens: totals.tokens_block,
|
|
551
573
|
# The tokens, cost and speeds per model, in order (ModelSegment.rows:
|
|
@@ -557,8 +579,8 @@ module Samagotchi
|
|
|
557
579
|
profile_source: @profile_source,
|
|
558
580
|
served_model: @served_model,
|
|
559
581
|
served_model_for: @served_model_for,
|
|
560
|
-
tool_calls_total:
|
|
561
|
-
tool_calls_by_tool:
|
|
582
|
+
tool_calls_total: calls,
|
|
583
|
+
tool_calls_by_tool: by_tool,
|
|
562
584
|
# The calls the model made under an alias's name (bash run as
|
|
563
585
|
# execute), by its own spelling; only when there are any.
|
|
564
586
|
**(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
|
|
@@ -582,6 +604,17 @@ module Samagotchi
|
|
|
582
604
|
}
|
|
583
605
|
end
|
|
584
606
|
|
|
607
|
+
# The finished turns and the running one. Caller holds the mutex.
|
|
608
|
+
def turn_count(turn) = @turn_records.size + (turn ? 1 : 0)
|
|
609
|
+
|
|
610
|
+
# Every tool call (loaded records, this process's, the running ones)
|
|
611
|
+
# and the calls by tool, a copy. Caller holds the mutex.
|
|
612
|
+
# @return [Array(Integer, Hash)]
|
|
613
|
+
def tool_call_counts(turn)
|
|
614
|
+
in_flight = turn ? turn.tool_calls_by_id.values : []
|
|
615
|
+
[@tool_records.size + in_flight.size, tally_into(@tool_tallies[:by_tool].dup, in_flight)]
|
|
616
|
+
end
|
|
617
|
+
|
|
585
618
|
# The model segments with the running turn in: the last one's when it
|
|
586
619
|
# runs on that model or names none yet, else a new one. Caller holds
|
|
587
620
|
# the mutex.
|
|
@@ -1199,7 +1232,8 @@ module Samagotchi
|
|
|
1199
1232
|
@totals = Totals.zero
|
|
1200
1233
|
@segments = []
|
|
1201
1234
|
@last_model_ref = nil
|
|
1202
|
-
|
|
1235
|
+
# ms: the finished calls' time by tool (#usage's active_ms).
|
|
1236
|
+
@tool_tallies = { by_tool: {}, aliased: {}, errors: 0, ms: {} }
|
|
1203
1237
|
end
|
|
1204
1238
|
|
|
1205
1239
|
def count_turn(record)
|
|
@@ -1219,6 +1253,15 @@ module Samagotchi
|
|
|
1219
1253
|
tally_into(@tool_tallies[:by_tool], [record])
|
|
1220
1254
|
called_as = record[:called_as].to_s
|
|
1221
1255
|
@tool_tallies[:aliased][called_as] = @tool_tallies[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
|
|
1256
|
+
count_tool_ms(record)
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
def count_tool_ms(record)
|
|
1260
|
+
name = record[:tool].to_s
|
|
1261
|
+
ms = record[:duration_ms]
|
|
1262
|
+
return if name.empty? || !ms.is_a?(Numeric) || USER_WAIT_TOOLS.include?(name)
|
|
1263
|
+
|
|
1264
|
+
@tool_tallies[:ms][name] = @tool_tallies[:ms].fetch(name, 0) + ms
|
|
1222
1265
|
end
|
|
1223
1266
|
|
|
1224
1267
|
def tally_into(counts, tools)
|