samagotchi 0.107.0 → 0.109.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 +69 -1
- data/docs/architecture.md +10 -4
- data/docs/configuration.md +81 -0
- data/docs/internals/engine.md +17 -6
- data/docs/internals/participants.md +28 -8
- data/docs/internals/remote-sessions.md +6 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/memory.md +20 -0
- data/docs/plugins.md +9 -2
- data/docs/releasing.md +11 -6
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +1 -1
- data/docs/testing.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
- data/lib/samagotchi/bridge.rb +28 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
- data/lib/samagotchi/child_reports.rb +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_sources.rb +1 -6
- data/lib/samagotchi/continue_offer.rb +8 -6
- data/lib/samagotchi/engine.rb +36 -25
- data/lib/samagotchi/guardrail_wiring.rb +9 -6
- data/lib/samagotchi/idle_scheduler.rb +17 -2
- data/lib/samagotchi/last_turn.rb +7 -0
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_orphans.rb +74 -0
- data/lib/samagotchi/memory_paths.rb +4 -4
- data/lib/samagotchi/parent_report.rb +11 -3
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/reply_wait.rb +7 -2
- data/lib/samagotchi/send_command/busy.rb +5 -2
- data/lib/samagotchi/send_command/wait_target.rb +16 -11
- data/lib/samagotchi/session_access/local.rb +46 -10
- data/lib/samagotchi/session_inbox.rb +12 -8
- data/lib/samagotchi/session_metrics.rb +7 -6
- data/lib/samagotchi/session_participants.rb +3 -1
- data/lib/samagotchi/steer.rb +11 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
- data/lib/samagotchi/tools/memory.rb +16 -9
- data/lib/samagotchi/turn_origin.rb +68 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +233 -26
- data/lib/samagotchi/web/grants.rb +8 -4
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +15 -118
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +7 -13
- data/lib/samagotchi/web/participants_admin.rb +136 -0
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +24 -0
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/participant.js +19 -5
- data/lib/samagotchi/web/public/participant_model.js +5 -0
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/participants_panel.js +2 -2
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/server.rb +70 -10
- data/lib/samagotchi/worker.rb +30 -25
- data/lib/samagotchi/worker_inbound.rb +12 -9
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- data/lib/samagotchi/working_set.rb +3 -2
- metadata +10 -1
|
@@ -5,6 +5,7 @@ require_relative "../kernel_loop"
|
|
|
5
5
|
require_relative "../steer"
|
|
6
6
|
require_relative "../tool_output_cut"
|
|
7
7
|
require_relative "../tool_row_fields"
|
|
8
|
+
require_relative "../turn_origin"
|
|
8
9
|
require_relative "turn_notice"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -70,7 +71,7 @@ module Samagotchi
|
|
|
70
71
|
# Whether the turn in progress is a participant's review turn (its
|
|
71
72
|
# author): the owner's input queues behind it then (Bridge's POST /turn).
|
|
72
73
|
def participant_turn?
|
|
73
|
-
@mutex.synchronize { !@turn.nil? && Steer.participant_author?(@turn[:author] || @turn
|
|
74
|
+
@mutex.synchronize { !@turn.nil? && Steer.participant_author?(@turn[:author] || TurnOrigin.from(@turn[:origin])&.author) }
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
# The turn in progress as conversation messages, for a plugin's
|
|
@@ -382,7 +383,7 @@ module Samagotchi
|
|
|
382
383
|
end
|
|
383
384
|
|
|
384
385
|
def dequeue(origins)
|
|
385
|
-
ids = origins.filter_map { |origin|
|
|
386
|
+
ids = origins.filter_map { |origin| TurnOrigin.from(origin)&.enqueued_id }
|
|
386
387
|
@queued.reject! { |entry| ids.include?(entry[:enqueued_id]) } unless ids.empty?
|
|
387
388
|
end
|
|
388
389
|
end
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -22,9 +22,11 @@ require_relative "engine"
|
|
|
22
22
|
require_relative "session_commands"
|
|
23
23
|
require_relative "queued_command"
|
|
24
24
|
require_relative "steer"
|
|
25
|
+
require_relative "turn_origin"
|
|
25
26
|
require_relative "participants"
|
|
26
27
|
require_relative "image_store"
|
|
27
28
|
require_relative "log"
|
|
29
|
+
require_relative "proxy_trace"
|
|
28
30
|
require_relative "version"
|
|
29
31
|
require_relative "worker_sidecar"
|
|
30
32
|
require_relative "relay_verifier"
|
|
@@ -119,6 +121,8 @@ module Samagotchi
|
|
|
119
121
|
@connection_threads = []
|
|
120
122
|
@stopped = false
|
|
121
123
|
@mutex = Monitor.new
|
|
124
|
+
@proxied_log_mutex = Mutex.new
|
|
125
|
+
@proxied_logged_at = nil
|
|
122
126
|
@open_streams = 0
|
|
123
127
|
# Open streams by the client_id a stream named (`?client_id=`); web
|
|
124
128
|
# tabs, through the `chi web` proxy, name none.
|
|
@@ -437,6 +441,10 @@ module Samagotchi
|
|
|
437
441
|
break
|
|
438
442
|
elsif browser_request?(headers)
|
|
439
443
|
write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
|
|
444
|
+
elsif proxied_request?(headers)
|
|
445
|
+
note_proxied_refused(request[:path])
|
|
446
|
+
write_json(io, 403, nil, { error: "proxied", detail: "the bridge answers chi's own clients on this machine, " \
|
|
447
|
+
"never a request through a proxy" })
|
|
440
448
|
elsif route.nil?
|
|
441
449
|
write_json(io, 404, { "Allow" => "GET, POST" },
|
|
442
450
|
{ error: "not_found", path: request[:path] })
|
|
@@ -523,6 +531,24 @@ module Samagotchi
|
|
|
523
531
|
# no Sec-Fetch-Site. A browser page always sends one of them (another
|
|
524
532
|
# website's text/plain POST needs no preflight), and a DNS-rebound page
|
|
525
533
|
# also has a foreign Host. Headers are the raw ones, lowercased.
|
|
534
|
+
# A request through a proxy (an auth proxy forwarding every port, as a
|
|
535
|
+
# work pod's does) carries a trace chi's own clients never send
|
|
536
|
+
# (ProxyTrace: X-Forwarded-*, Forwarded, X-Real-IP, X-Auth-Request-*…,
|
|
537
|
+
# underscore twins too): refused, whoever signed in to the proxy. chi
|
|
538
|
+
# web's stream proxy forwards Last-Event-ID only (ProxyStreamBody).
|
|
539
|
+
def proxied_request?(headers) = headers.keys.any? { |name| ProxyTrace.header?(name) }
|
|
540
|
+
|
|
541
|
+
PROXIED_LOG_INTERVAL = 60.0
|
|
542
|
+
|
|
543
|
+
# At most once a minute: a scan through a forward would fill the log.
|
|
544
|
+
def note_proxied_refused(path)
|
|
545
|
+
now = monotonic_now
|
|
546
|
+
due = @proxied_log_mutex.synchronize do
|
|
547
|
+
(@proxied_logged_at.nil? || now - @proxied_logged_at >= PROXIED_LOG_INTERVAL).tap { |fresh| @proxied_logged_at = now if fresh }
|
|
548
|
+
end
|
|
549
|
+
Log.warn(:bridge, "proxied_refused", path: path) if due
|
|
550
|
+
end
|
|
551
|
+
|
|
526
552
|
def browser_request?(headers)
|
|
527
553
|
return true if headers.key?("origin")
|
|
528
554
|
return true if headers.key?("sec-fetch-site") && headers["sec-fetch-site"].downcase != "none"
|
|
@@ -1032,14 +1058,14 @@ module Samagotchi
|
|
|
1032
1058
|
# +author+'s turns running or waiting: the running turn when it is
|
|
1033
1059
|
# theirs, and their queued ones. Read with the event log held.
|
|
1034
1060
|
def pending_turns(author)
|
|
1035
|
-
running =
|
|
1061
|
+
running = TurnOrigin.of(@accumulator.current_turn&.dig(:origin)).participant_author == author ? 1 : 0
|
|
1036
1062
|
running + @accumulator.queued.count { |row| row[:author] == author }
|
|
1037
1063
|
end
|
|
1038
1064
|
|
|
1039
1065
|
# 403 not_permitted for a participant's +what+ (a command, a shell
|
|
1040
1066
|
# line), with its audit line.
|
|
1041
1067
|
def participant_refused(sid, author, what)
|
|
1042
|
-
Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant:
|
|
1068
|
+
Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: Steer.participant_id(author), what: what,
|
|
1043
1069
|
reason: "not_permitted")
|
|
1044
1070
|
[{}, 403, { error: "not_permitted", detail: Participants::NOT_PERMITTED }]
|
|
1045
1071
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cost-cap
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.3
|
|
4
4
|
scope: system
|
|
5
5
|
description: "A per-session $ cap: at the cap the turn pauses on Continue (another cap's worth) / Stop, a warning at 80% goes to the user only; /cap shows or sets it"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:57362669f6b499fb70103a11eb6582e261b69103f51ee1ac36496a04717f5d9b
|
|
10
10
|
# ctx.cost is new in chi 0.83.0; ask_user's effects: in the next chi
|
|
11
11
|
requires_chi: ">= 0.92.0"
|
|
12
12
|
# session_settings: as a mapping of keys to types is new
|
|
@@ -44,6 +44,7 @@ class Plugin
|
|
|
44
44
|
@warned = nil # [session id, cap] the share warning was given for
|
|
45
45
|
@unknown_warned = nil # the session id the "can't be enforced" notice was given for
|
|
46
46
|
@unread_warned = nil # the session id the "isn't a number" notice was given for
|
|
47
|
+
@corrupt_warned = nil # the session id the "state file doesn't read" notice was given for
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def register(chi)
|
|
@@ -104,6 +105,7 @@ class Plugin
|
|
|
104
105
|
def check(ctx)
|
|
105
106
|
resolved = resolve(ctx)
|
|
106
107
|
unreadable(ctx, resolved)
|
|
108
|
+
corrupt(ctx, resolved)
|
|
107
109
|
cap = resolved[:cap]
|
|
108
110
|
return unless cap
|
|
109
111
|
|
|
@@ -140,10 +142,23 @@ class Plugin
|
|
|
140
142
|
return if bad.nil? || bad.empty? || !first_time?(:@unread_warned, resolved[:session])
|
|
141
143
|
|
|
142
144
|
named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
ctx.notify("cost-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using(resolved)}",
|
|
146
|
+
level: :warn)
|
|
145
147
|
end
|
|
146
148
|
|
|
149
|
+
# Once per session: a state file that is there but doesn't read (not
|
|
150
|
+
# JSON, or not a mapping) holds no /cap cap, so the settings' is used;
|
|
151
|
+
# said rather than a /cap cap silently gone.
|
|
152
|
+
def corrupt(ctx, resolved)
|
|
153
|
+
path = resolved[:corrupt]
|
|
154
|
+
return unless path && first_time?(:@corrupt_warned, resolved[:session])
|
|
155
|
+
|
|
156
|
+
ctx.notify("cost-cap: #{path} doesn't read, its /cap cap is ignored; #{using(resolved)}", level: :warn)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# "using default: ($5.00)", or "no cap".
|
|
160
|
+
def using(resolved) = resolved[:cap] ? "using #{resolved[:key]}: (#{money(resolved[:cap])})" : "no cap"
|
|
161
|
+
|
|
147
162
|
def at_cap(ctx, resolved, cost)
|
|
148
163
|
cap = resolved[:cap]
|
|
149
164
|
raised = raised_cap(cap, resolved[:step] || cap, cost.total)
|
|
@@ -222,20 +237,29 @@ class Plugin
|
|
|
222
237
|
cached = @mutex.synchronize { @resolved }
|
|
223
238
|
return cached if cached && cached[:session] == session
|
|
224
239
|
|
|
225
|
-
|
|
240
|
+
saved, corrupt = state(ctx, session)
|
|
241
|
+
resolved = { session: session, **(saved || configured(ctx)), corrupt: corrupt }
|
|
226
242
|
@mutex.synchronize { @resolved = resolved }
|
|
227
243
|
end
|
|
228
244
|
|
|
245
|
+
# The state file's cap and step ({cap:, step:}), nil without one (or
|
|
246
|
+
# one without a "cap"), and the file's path when it is there but
|
|
247
|
+
# doesn't read (logged; the user is told by #corrupt), else nil.
|
|
229
248
|
def state(ctx, session)
|
|
230
|
-
return nil unless session
|
|
249
|
+
return [nil, nil] unless session
|
|
231
250
|
|
|
232
|
-
|
|
233
|
-
|
|
251
|
+
path = state_path(ctx, session)
|
|
252
|
+
saved = JSON.parse(File.read(path))
|
|
253
|
+
raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
|
|
254
|
+
return [nil, nil] unless saved.key?("cap")
|
|
234
255
|
|
|
235
256
|
cap = self.class.amount(saved["cap"])
|
|
236
|
-
{ cap: cap, step: self.class.amount(saved["step"]) || cap }
|
|
237
|
-
rescue Errno::ENOENT
|
|
238
|
-
nil
|
|
257
|
+
[{ cap: cap, step: self.class.amount(saved["step"]) || cap }, nil]
|
|
258
|
+
rescue Errno::ENOENT
|
|
259
|
+
[nil, nil]
|
|
260
|
+
rescue JSON::ParserError => e
|
|
261
|
+
ctx.log.warn(:costcap_state_unreadable, path: path, error: e.message.to_s[0, 200])
|
|
262
|
+
[nil, path]
|
|
239
263
|
end
|
|
240
264
|
|
|
241
265
|
# The first of cap:, child: (a delegate's) and default: that is set and
|
|
@@ -259,14 +283,32 @@ class Plugin
|
|
|
259
283
|
share.is_a?(Numeric) && share.positive? ? share : nil
|
|
260
284
|
end
|
|
261
285
|
|
|
286
|
+
# A state file there that doesn't read (#state) is written over: logged
|
|
287
|
+
# and said, never a silent replace.
|
|
262
288
|
def save(ctx, session, data)
|
|
263
289
|
path = state_path(ctx, session)
|
|
264
290
|
FileUtils.mkdir_p(File.dirname(path))
|
|
291
|
+
replacing = unreadable_file?(path)
|
|
265
292
|
Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
|
|
293
|
+
replaced(ctx, path) if replacing
|
|
266
294
|
cap = self.class.amount(data["cap"])
|
|
267
295
|
@mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
|
|
268
296
|
end
|
|
269
297
|
|
|
298
|
+
# Whether +path+ is there but doesn't read (not JSON, or not a mapping).
|
|
299
|
+
def unreadable_file?(path)
|
|
300
|
+
!JSON.parse(File.read(path)).is_a?(Hash)
|
|
301
|
+
rescue Errno::ENOENT
|
|
302
|
+
false
|
|
303
|
+
rescue JSON::ParserError
|
|
304
|
+
true
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def replaced(ctx, path)
|
|
308
|
+
ctx.log.warn(:costcap_state_replaced, path: path)
|
|
309
|
+
ctx.notify("cost-cap: #{path} didn't read; replaced with the new cap", level: :warn)
|
|
310
|
+
end
|
|
311
|
+
|
|
270
312
|
def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
|
|
271
313
|
|
|
272
314
|
def money(value) = self.class.money(value)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.109.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:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:df3f8da539a9f5812f79e089c2c8b9894da6124a89654f292160046a6bb060da
|
|
9
9
|
config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:dfb3982cf281bf7920d018ea65b68b5755f86d8bd1e03bab9db42393a94ec7e0
|
|
11
11
|
delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
|
|
@@ -26,7 +26,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
|
|
|
26
26
|
- `name: "entry", scope: "system"|"project"` — scoped read.
|
|
27
27
|
- `name: "a, b, c"` — comma-separated, concatenated with `\n\n---\n\n`.
|
|
28
28
|
- `name: ""` (optionally with `scope`) — reads the index. Empty name with no scope returns both indexes concatenated (`Project memories:` / `System memories:`). Use this at start to discover what exists.
|
|
29
|
-
- Returns `Error: memory not found: …` on miss — treat as missing, not fatal.
|
|
29
|
+
- Returns `Error: memory not found: …` on miss — treat as missing, not fatal. A `Hint:` line after it means this project's memory folder looks orphaned (moved from another path); tell the user the folders it names, don't rename them yourself.
|
|
30
30
|
|
|
31
31
|
### `memory_write` — write/update entries
|
|
32
32
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: work-cap
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.3
|
|
4
4
|
scope: system
|
|
5
5
|
description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:3c93baec9ec20e147803311e1eb16d699dc169b52680374c1e6ff865a82204ee
|
|
10
10
|
# ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
|
|
11
11
|
requires_chi: ">= 0.100.0"
|
|
12
12
|
# chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
|
|
@@ -63,6 +63,7 @@ class Plugin
|
|
|
63
63
|
@resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
|
|
64
64
|
@warned = {} # meter => [session id, cap] the share warning was given for
|
|
65
65
|
@unread_warned = nil # the session id the "isn't a number" notice was given for
|
|
66
|
+
@corrupt_warned = nil # the session id the "state file doesn't read" notice was given for
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
def register(chi)
|
|
@@ -126,6 +127,7 @@ class Plugin
|
|
|
126
127
|
def check(ctx)
|
|
127
128
|
resolved = resolve(ctx)
|
|
128
129
|
unreadable(ctx, resolved)
|
|
130
|
+
corrupt(ctx, resolved)
|
|
129
131
|
capped = resolved[:meters].select { |_meter, meter_cap| meter_cap[:cap] }
|
|
130
132
|
return if capped.empty?
|
|
131
133
|
|
|
@@ -161,11 +163,26 @@ class Plugin
|
|
|
161
163
|
return if bad.empty? || !first_time?(:@unread_warned, resolved[:session])
|
|
162
164
|
|
|
163
165
|
named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
|
|
166
|
+
ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using(resolved)}",
|
|
167
|
+
level: :warn)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Once per session: a state file that is there but doesn't read (not
|
|
171
|
+
# JSON, or not a mapping) holds no /workcap caps, so the settings' are
|
|
172
|
+
# used; said rather than a /workcap cap silently gone.
|
|
173
|
+
def corrupt(ctx, resolved)
|
|
174
|
+
path = resolved[:corrupt]
|
|
175
|
+
return unless path && first_time?(:@corrupt_warned, resolved[:session])
|
|
176
|
+
|
|
177
|
+
ctx.notify("work-cap: #{path} doesn't read, its /workcap caps are ignored; #{using(resolved)}", level: :warn)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# "using tool_calls: (400), minutes: (60)", or "no cap".
|
|
181
|
+
def using(resolved)
|
|
164
182
|
used = resolved[:meters].values.filter_map do |meter_cap|
|
|
165
183
|
"#{meter_cap[:key]}: (#{count(meter_cap[:cap])})" if meter_cap[:cap]
|
|
166
184
|
end
|
|
167
|
-
|
|
168
|
-
ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using}", level: :warn)
|
|
185
|
+
used.empty? ? "no cap" : "using #{used.join(", ")}"
|
|
169
186
|
end
|
|
170
187
|
|
|
171
188
|
def at_cap(ctx, resolved, reached, usage, used)
|
|
@@ -280,29 +297,37 @@ class Plugin
|
|
|
280
297
|
cached = @mutex.synchronize { @resolved }
|
|
281
298
|
return cached if cached && cached[:session] == session && cached[:settings] == settings
|
|
282
299
|
|
|
283
|
-
saved = state(ctx, session)
|
|
300
|
+
saved, corrupt = state(ctx, session)
|
|
284
301
|
configured = configured(ctx)
|
|
285
302
|
meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
|
|
286
|
-
resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable]
|
|
303
|
+
resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable],
|
|
304
|
+
corrupt: corrupt }
|
|
287
305
|
@mutex.synchronize { @resolved = resolved }
|
|
288
306
|
end
|
|
289
307
|
|
|
290
|
-
# The state file's meters ({meter => {cap:, step:}}), {} without one
|
|
308
|
+
# The state file's meters ({meter => {cap:, step:}}), {} without one,
|
|
309
|
+
# and the file's path when it is there but doesn't read (logged; the
|
|
310
|
+
# user is told by #corrupt), else nil.
|
|
291
311
|
def state(ctx, session)
|
|
292
|
-
return {} unless session
|
|
312
|
+
return [{}, nil] unless session
|
|
293
313
|
|
|
294
|
-
|
|
295
|
-
|
|
314
|
+
path = state_path(ctx, session)
|
|
315
|
+
saved = JSON.parse(File.read(path))
|
|
316
|
+
raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
|
|
296
317
|
|
|
297
|
-
METERS.each_with_object({}) do |meter,
|
|
318
|
+
meters = METERS.each_with_object({}) do |meter, found|
|
|
298
319
|
entry = saved[meter]
|
|
299
320
|
next unless entry.is_a?(Hash) && entry.key?("cap")
|
|
300
321
|
|
|
301
322
|
cap = self.class.amount(entry["cap"], meter)
|
|
302
|
-
|
|
323
|
+
found[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
|
|
303
324
|
end
|
|
304
|
-
|
|
305
|
-
|
|
325
|
+
[meters, nil]
|
|
326
|
+
rescue Errno::ENOENT
|
|
327
|
+
[{}, nil]
|
|
328
|
+
rescue JSON::ParserError => e
|
|
329
|
+
ctx.log.warn(:workcap_state_unreadable, path: path, error: e.message.to_s[0, 200])
|
|
330
|
+
[{}, path]
|
|
306
331
|
end
|
|
307
332
|
|
|
308
333
|
# Per meter the first of its own value, child:'s (a delegate's) and
|
|
@@ -358,21 +383,32 @@ class Plugin
|
|
|
358
383
|
end
|
|
359
384
|
|
|
360
385
|
# Write one meter's cap into the session's state file (the other kept).
|
|
386
|
+
# One there that doesn't read (#state) is written over: logged and said,
|
|
387
|
+
# never a silent replace.
|
|
361
388
|
def save_meter(ctx, session, meter, data)
|
|
362
389
|
path = state_path(ctx, session)
|
|
363
390
|
FileUtils.mkdir_p(File.dirname(path))
|
|
364
391
|
saved = begin
|
|
365
392
|
JSON.parse(File.read(path))
|
|
366
|
-
rescue Errno::ENOENT
|
|
393
|
+
rescue Errno::ENOENT
|
|
367
394
|
{}
|
|
395
|
+
rescue JSON::ParserError
|
|
396
|
+
nil
|
|
368
397
|
end
|
|
369
|
-
|
|
398
|
+
replacing = !saved.is_a?(Hash)
|
|
399
|
+
saved = {} if replacing
|
|
370
400
|
saved[meter] = data.transform_values { |value| whole(value) }
|
|
371
401
|
Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
|
|
402
|
+
replaced(ctx, path) if replacing
|
|
372
403
|
# Resolved again at the next use, from the file now holding the meter.
|
|
373
404
|
@mutex.synchronize { @resolved = nil }
|
|
374
405
|
end
|
|
375
406
|
|
|
407
|
+
def replaced(ctx, path)
|
|
408
|
+
ctx.log.warn(:workcap_state_replaced, path: path)
|
|
409
|
+
ctx.notify("work-cap: #{path} didn't read; replaced with the new cap", level: :warn)
|
|
410
|
+
end
|
|
411
|
+
|
|
376
412
|
# 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
|
|
377
413
|
def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
|
|
378
414
|
|
|
@@ -7,6 +7,7 @@ require_relative "session"
|
|
|
7
7
|
require_relative "session_inbox"
|
|
8
8
|
require_relative "reply_wait"
|
|
9
9
|
require_relative "steer"
|
|
10
|
+
require_relative "turn_origin"
|
|
10
11
|
require_relative "log"
|
|
11
12
|
require_relative "config"
|
|
12
13
|
require_relative "tools/delegate_wait"
|
|
@@ -138,7 +139,7 @@ module Samagotchi
|
|
|
138
139
|
def initialize(relay: nil, **) = super
|
|
139
140
|
|
|
140
141
|
def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
|
|
141
|
-
def origin =
|
|
142
|
+
def origin = TurnOrigin.new(client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}")
|
|
142
143
|
end
|
|
143
144
|
|
|
144
145
|
# @param predecessor [String, nil] the link this session continues
|
|
@@ -96,7 +96,7 @@ module Samagotchi
|
|
|
96
96
|
@stdout.flush
|
|
97
97
|
@reported = true
|
|
98
98
|
end
|
|
99
|
-
error_line("#{command_name}: still running: #{@access.invocation
|
|
99
|
+
error_line("#{command_name}: still running: #{ParentReport.still_running_hint(id, report_turn_id, via: @access.invocation)}")
|
|
100
100
|
Exit::INTERRUPTED
|
|
101
101
|
rescue ArgumentError
|
|
102
102
|
error_line("#{command_name}: the session is gone (deleted while waiting)")
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -316,6 +316,10 @@ module Samagotchi
|
|
|
316
316
|
Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
317
317
|
Entry.new(key: "web.participants.cap", yaml_path: %w[web participants cap], type: :float, default: 2.0, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
318
318
|
Entry.new(key: "web.participants.queue", yaml_path: %w[web participants queue], type: :integer, default: 3, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
319
|
+
# chi web behind an auth proxy (Web::PublicUrl, docs/configuration.md "Behind an auth proxy").
|
|
320
|
+
Entry.new(key: "web.public_url", yaml_path: %w[web public_url], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
|
|
321
|
+
Entry.new(key: "web.proxy.identity_header", yaml_path: %w[web proxy identity_header], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
|
|
322
|
+
Entry.new(key: "web.proxy.owner", yaml_path: %w[web proxy owner], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
|
|
319
323
|
|
|
320
324
|
Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
|
|
321
325
|
Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
|
|
@@ -359,12 +359,7 @@ module Samagotchi
|
|
|
359
359
|
def project_location_for(project_root, state_dir: nil)
|
|
360
360
|
return nil if project_root.to_s.empty?
|
|
361
361
|
|
|
362
|
-
project_location(
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
# MemoryPaths.project_key for a root already resolved.
|
|
366
|
-
def project_key_of(root)
|
|
367
|
-
"#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
|
|
362
|
+
project_location(MemoryPaths.key_for_root(project_root), state_dir: state_dir)
|
|
368
363
|
end
|
|
369
364
|
|
|
370
365
|
# A session's sources: its own, then the project's it doesn't shadow
|
|
@@ -4,6 +4,7 @@ require "time"
|
|
|
4
4
|
require_relative "last_turn"
|
|
5
5
|
require_relative "log"
|
|
6
6
|
require_relative "steer"
|
|
7
|
+
require_relative "turn_origin"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# A session worker's continue offer: what it announces when a turn runs out
|
|
@@ -71,6 +72,7 @@ module Samagotchi
|
|
|
71
72
|
|
|
72
73
|
# A prompt (or a reminder turn) taken while a continue is offered
|
|
73
74
|
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
75
|
+
# @param origin [TurnOrigin] the taken turn's
|
|
74
76
|
def drop(origin)
|
|
75
77
|
return unless awaiting?
|
|
76
78
|
|
|
@@ -78,7 +80,7 @@ module Samagotchi
|
|
|
78
80
|
close("dropped")
|
|
79
81
|
@engine.synchronize_events do
|
|
80
82
|
@turn_flow.drop_offer!
|
|
81
|
-
@engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin
|
|
83
|
+
@engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin.client_id)
|
|
82
84
|
end
|
|
83
85
|
@engine.record_activity
|
|
84
86
|
end
|
|
@@ -149,7 +151,7 @@ module Samagotchi
|
|
|
149
151
|
offer = @turn_flow.offer
|
|
150
152
|
resume_from = @turn_flow.resume_from
|
|
151
153
|
enqueued_id = offer_turn_id
|
|
152
|
-
author =
|
|
154
|
+
author = @engine.session&.last_turn&.participant_author
|
|
153
155
|
close("answered")
|
|
154
156
|
if author && (reason = @participant_refused.call(author))
|
|
155
157
|
@continue_steer = nil
|
|
@@ -166,7 +168,7 @@ module Samagotchi
|
|
|
166
168
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
167
169
|
return announce_offer(offer)
|
|
168
170
|
end
|
|
169
|
-
origin =
|
|
171
|
+
origin = TurnOrigin.new(client_id: command.client_id, enqueued_id: enqueued_id, author: author)
|
|
170
172
|
@run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
|
|
171
173
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
172
174
|
if error
|
|
@@ -192,7 +194,7 @@ module Samagotchi
|
|
|
192
194
|
private
|
|
193
195
|
|
|
194
196
|
def refusal_of_offer_author
|
|
195
|
-
author =
|
|
197
|
+
author = @engine.session&.last_turn&.participant_author
|
|
196
198
|
author && @participant_refused.call(author)
|
|
197
199
|
end
|
|
198
200
|
|
|
@@ -204,11 +206,11 @@ module Samagotchi
|
|
|
204
206
|
# participant turns go on). The question goes with the next close.
|
|
205
207
|
# Called with the event log held.
|
|
206
208
|
def refuse_continue(reason, command)
|
|
207
|
-
author = @engine.session&.last_turn&.
|
|
209
|
+
author = @engine.session&.last_turn&.participant_author
|
|
208
210
|
enqueued_id = offer_turn_id
|
|
209
211
|
@engine.announce(type: :continue_resolved, decision: "refused", reason: reason.to_s, client_id: command.client_id)
|
|
210
212
|
@engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
|
|
211
|
-
text: format(REFUSED_NOTICE, who:
|
|
213
|
+
text: format(REFUSED_NOTICE, who: Steer.participant_id(author), reason: REFUSED_WHY.fetch(reason.to_s, reason.to_s)))
|
|
212
214
|
@turn_flow.abort_continue!
|
|
213
215
|
session = @engine.session
|
|
214
216
|
session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3), enqueued_id: enqueued_id) if session
|