samagotchi 0.104.0 → 0.106.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 +70 -1
- data/docs/architecture.md +6 -3
- data/docs/cli.md +62 -19
- data/docs/configuration.md +10 -6
- data/docs/guardrails.md +69 -0
- data/docs/hooks.md +9 -0
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/engine.md +37 -1
- data/docs/internals/participants.md +229 -21
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/internals/working-set.md +12 -5
- data/docs/plugins.md +2 -0
- data/docs/testing.md +23 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -4
- data/lib/samagotchi/bridge.rb +45 -8
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +21 -9
- data/lib/samagotchi/config.rb +17 -2
- data/lib/samagotchi/continue_offer.rb +13 -3
- data/lib/samagotchi/delivery.rb +10 -0
- data/lib/samagotchi/engine.rb +98 -8
- data/lib/samagotchi/guardrail_wiring.rb +13 -3
- data/lib/samagotchi/guardrails/review_boundary.rb +148 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hook_routes.rb +5 -2
- data/lib/samagotchi/hooks/registry.rb +12 -3
- data/lib/samagotchi/idle_recap.rb +9 -0
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/parent_report.rb +17 -5
- data/lib/samagotchi/participant_messages.rb +16 -0
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +29 -6
- data/lib/samagotchi/participants_watch.rb +90 -0
- data/lib/samagotchi/plugin/context.rb +7 -1
- data/lib/samagotchi/plugin/side_question.rb +9 -1
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/reply_wait/poll.rb +18 -3
- data/lib/samagotchi/run_options.rb +1 -1
- data/lib/samagotchi/send_command.rb +41 -3
- data/lib/samagotchi/session_access/contract.rb +2 -0
- data/lib/samagotchi/session_access/local.rb +31 -9
- data/lib/samagotchi/session_access/remote.rb +19 -2
- data/lib/samagotchi/session_access/web_bridge_client.rb +4 -2
- data/lib/samagotchi/session_access.rb +6 -3
- data/lib/samagotchi/session_commands.rb +6 -0
- data/lib/samagotchi/session_inbox.rb +31 -5
- data/lib/samagotchi/session_manager.rb +43 -3
- data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
- data/lib/samagotchi/session_metrics.rb +94 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +10 -7
- data/lib/samagotchi/share_command.rb +61 -22
- data/lib/samagotchi/side_usage.rb +4 -2
- data/lib/samagotchi/steer.rb +120 -11
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +94 -22
- data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
- data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
- data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/testing/host.rb +4 -2
- data/lib/samagotchi/testing/recorder.rb +3 -1
- data/lib/samagotchi/toolkit.rb +21 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +45 -20
- data/lib/samagotchi/web/grants.rb +89 -8
- data/lib/samagotchi/web/participant_access.rb +206 -35
- data/lib/samagotchi/web/participant_review.rb +165 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +157 -0
- data/lib/samagotchi/web/participant_view.rb +415 -0
- data/lib/samagotchi/web/public/app.js +15 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/data.js +20 -0
- data/lib/samagotchi/web/public/index.html +75 -30
- data/lib/samagotchi/web/public/info_bar.js +12 -5
- data/lib/samagotchi/web/public/info_bar_model.js +8 -5
- data/lib/samagotchi/web/public/participant.html +119 -0
- data/lib/samagotchi/web/public/participant.js +300 -0
- data/lib/samagotchi/web/public/participant_model.js +160 -0
- data/lib/samagotchi/web/public/participant_names.js +21 -0
- data/lib/samagotchi/web/public/participants_panel.js +311 -0
- data/lib/samagotchi/web/public/stream_handlers.js +8 -1
- data/lib/samagotchi/web/public/tokens.css +12 -0
- data/lib/samagotchi/web/public/turn_events.js +16 -5
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +4 -4
- data/lib/samagotchi/web/qr.rb +14 -0
- data/lib/samagotchi/web/running.rb +52 -0
- data/lib/samagotchi/web/server.rb +13 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -0
- data/lib/samagotchi/worker.rb +125 -11
- data/lib/samagotchi/worker_inbound.rb +12 -1
- metadata +17 -1
|
@@ -51,7 +51,8 @@ module Samagotchi
|
|
|
51
51
|
load_tools: ->(names, bundle) { recorder.load_tools(names, bundle) },
|
|
52
52
|
cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
|
|
53
53
|
cost: -> { cost(recorder[:cost]) },
|
|
54
|
-
usage: -> { usage(recorder[:usage]) }
|
|
54
|
+
usage: -> { usage(recorder[:usage]) },
|
|
55
|
+
turn_author: -> { recorder[:turn_author] }
|
|
55
56
|
)
|
|
56
57
|
end
|
|
57
58
|
|
|
@@ -119,7 +120,8 @@ module Samagotchi
|
|
|
119
120
|
ask_user: ->(question) { recorder.ask_user(question) },
|
|
120
121
|
stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
|
|
121
122
|
steer: ->(text:, hook:) { recorder.steer(text, hook) },
|
|
122
|
-
stop_generation: ->(reason:, hook:) { recorder.stop(:generation, reason, hook) }
|
|
123
|
+
stop_generation: ->(reason:, hook:) { recorder.stop(:generation, reason, hook) },
|
|
124
|
+
turn_author: -> { recorder[:turn_author] }
|
|
123
125
|
)
|
|
124
126
|
end
|
|
125
127
|
end
|
|
@@ -41,7 +41,9 @@ module Samagotchi
|
|
|
41
41
|
# ctx.usage: nil (zero), a Hash of the parts (turns:, tool_calls:,
|
|
42
42
|
# tool_calls_by_tool:, steps:, active_ms:, tool_ms_by_tool:) or a
|
|
43
43
|
# callable giving one.
|
|
44
|
-
usage: nil
|
|
44
|
+
usage: nil,
|
|
45
|
+
# ctx.turn_author and the hooks' author: (a participant's "p:<id>"), nil for the owner.
|
|
46
|
+
turn_author: nil
|
|
45
47
|
}.freeze
|
|
46
48
|
|
|
47
49
|
attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -62,6 +62,9 @@ module Samagotchi
|
|
|
62
62
|
FULL = "full"
|
|
63
63
|
LEAN = "lean"
|
|
64
64
|
CHILD = "child"
|
|
65
|
+
# A participant's turn (Guardrails::ReviewBoundary): the name is
|
|
66
|
+
# reserved, so config and plugins can't redefine the boundary.
|
|
67
|
+
REVIEW = "review"
|
|
65
68
|
|
|
66
69
|
# A name that can't be resolved (unknown, a cycle).
|
|
67
70
|
class Unresolved < StandardError; end
|
|
@@ -140,13 +143,20 @@ module Samagotchi
|
|
|
140
143
|
ask_user_question web_fetch context_read
|
|
141
144
|
].freeze
|
|
142
145
|
|
|
146
|
+
# The built-ins a participant's turn may call (REVIEW): reads only.
|
|
147
|
+
# ReviewBoundary denies everything else, whatever the session declares
|
|
148
|
+
# (D3: the declared tools stay the session's), the forget layer's
|
|
149
|
+
# forget_outputs too (a layer tool, declared with every kit).
|
|
150
|
+
REVIEW_TOOLS = %w[read memory_read].freeze
|
|
151
|
+
|
|
143
152
|
BUILTIN_KITS = [
|
|
144
153
|
Kit.new(name: FULL, tools: :all),
|
|
145
154
|
Kit.new(name: LEAN, tools: LEAN_TOOLS),
|
|
146
155
|
# A delegate child: it reports to its parent (send_note; the
|
|
147
156
|
# delegated-by line names it) and leaves memory writes to it, and
|
|
148
157
|
# starts no children of its own (removed: not even through call_tool).
|
|
149
|
-
Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write delegate delegate_result])
|
|
158
|
+
Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write delegate delegate_result]),
|
|
159
|
+
Kit.new(name: REVIEW, tools: REVIEW_TOOLS)
|
|
150
160
|
].to_h { |kit| [kit.name, kit] }.freeze
|
|
151
161
|
|
|
152
162
|
# What a toolkit declares, the Registry's filter: every built-in
|
|
@@ -256,12 +266,18 @@ module Samagotchi
|
|
|
256
266
|
base.changed(add: kit.add, remove: kit.remove).with(name: name, prompt: prompt.to_s.empty? ? nil : prompt)
|
|
257
267
|
end
|
|
258
268
|
|
|
269
|
+
# config.yml's toolkit.kits.review, refused: the review kit is the
|
|
270
|
+
# participants' boundary, which config may not widen.
|
|
271
|
+
RESERVED_REVIEW = "toolkit.kits.review: review is reserved for participants' turns (chi share); " \
|
|
272
|
+
"name your kit something else"
|
|
273
|
+
|
|
259
274
|
# The keys a config kit entry takes.
|
|
260
275
|
KIT_KEYS = %w[base add remove prompt].freeze
|
|
261
276
|
|
|
262
277
|
# The kits known: the built-ins, then config's toolkit.kits, then the
|
|
263
278
|
# plugins' (chi.toolkit). A config kit with a built-in's name is left
|
|
264
|
-
# out (one it can't redefine
|
|
279
|
+
# out (one it can't redefine; review, the participants' boundary, is
|
|
280
|
+
# reserved) with a line in +errors+, and so is one
|
|
265
281
|
# that isn't a {base:, add:, remove:, prompt:} map. One with another key
|
|
266
282
|
# (a typo, a tools: list) still loads, with a line naming the key, and
|
|
267
283
|
# so does one whose prompt isn't text or is over PROMPT_MAX characters:
|
|
@@ -274,7 +290,9 @@ module Samagotchi
|
|
|
274
290
|
kits = BUILTIN_KITS.dup
|
|
275
291
|
(config.is_a?(Hash) ? config : {}).each do |name, spec|
|
|
276
292
|
name = name.to_s
|
|
277
|
-
if
|
|
293
|
+
if name == REVIEW
|
|
294
|
+
errors << RESERVED_REVIEW
|
|
295
|
+
elsif kits.key?(name)
|
|
278
296
|
errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
|
|
279
297
|
elsif !spec.is_a?(Hash)
|
|
280
298
|
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:}"
|
|
@@ -159,13 +159,16 @@ module Samagotchi
|
|
|
159
159
|
# Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
|
|
160
160
|
# @param prompt [String, nil]
|
|
161
161
|
# @param enqueued_id [String, nil] the input the turn runs
|
|
162
|
-
|
|
162
|
+
# @param author [Hash] a participant's prompt's author fields
|
|
163
|
+
# (Engine#author_fields), so a kill before the first boundary leaves
|
|
164
|
+
# it theirs; {} for the owner's
|
|
165
|
+
def begin_turn(prompt: nil, enqueued_id: nil, author: {})
|
|
163
166
|
@boundaries = 0
|
|
164
167
|
@enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
165
168
|
@written_at = nil
|
|
166
169
|
@started_at = Time.now.iso8601(3)
|
|
167
170
|
messages = Array(@session.messages).dup
|
|
168
|
-
messages << { role: "user", content: prompt } unless prompt.to_s.empty?
|
|
171
|
+
messages << { role: "user", content: prompt, **author } unless prompt.to_s.empty?
|
|
169
172
|
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
|
|
170
173
|
enqueued_id: @enqueued_id) }
|
|
171
174
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -55,6 +55,7 @@ require_relative "session_summary"
|
|
|
55
55
|
require_relative "session_access_routes"
|
|
56
56
|
require_relative "principal"
|
|
57
57
|
require_relative "participant_access"
|
|
58
|
+
require_relative "participant_review"
|
|
58
59
|
require_relative "../log"
|
|
59
60
|
require_relative "../paths"
|
|
60
61
|
require_relative "../redacted_inspect"
|
|
@@ -73,6 +74,7 @@ module Samagotchi
|
|
|
73
74
|
include RedactedInspect
|
|
74
75
|
include SessionAccessRoutes
|
|
75
76
|
include ParticipantAccess
|
|
77
|
+
include ParticipantReview
|
|
76
78
|
|
|
77
79
|
DEFAULT_HOST = BridgeClient::HOST
|
|
78
80
|
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
@@ -191,25 +193,26 @@ module Samagotchi
|
|
|
191
193
|
["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
|
|
192
194
|
["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
|
|
193
195
|
["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
|
|
194
|
-
["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :
|
|
196
|
+
["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
|
|
195
197
|
["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
|
|
196
198
|
["GET", %r{\A/api/info\z}, :handle_info, :participant],
|
|
197
199
|
["GET", %r{\A/api/models\z}, :handle_models, :owner],
|
|
198
200
|
["GET", %r{\A/api/history\z}, :handle_history, :owner],
|
|
199
201
|
["GET", %r{\A/api/events\z}, :handle_events, :owner],
|
|
200
202
|
["POST", %r{\A/api/render\z}, :handle_render, :owner],
|
|
201
|
-
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream, :
|
|
203
|
+
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream, :participant],
|
|
204
|
+
["GET", %r{\A/api/sessions/([^/]+)/review\z}, :handle_review, :participant, :session],
|
|
202
205
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :owner, :session],
|
|
203
206
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :owner, :session],
|
|
204
207
|
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
|
|
205
|
-
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :
|
|
208
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :participant, :session],
|
|
206
209
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
|
|
207
210
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
|
|
208
211
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
|
|
209
212
|
["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
|
|
210
|
-
["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :
|
|
211
|
-
["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :
|
|
212
|
-
["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :
|
|
213
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :participant, :session],
|
|
214
|
+
["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :participant, :session],
|
|
215
|
+
["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :participant, :session],
|
|
213
216
|
["POST", %r{\A/api/sessions/([^/]+)/notes\z}, :handle_notes, :owner, :session],
|
|
214
217
|
["POST", %r{\A/api/sessions/([^/]+)/wake\z}, :handle_wake, :owner, :session],
|
|
215
218
|
["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
|
|
@@ -232,6 +235,7 @@ module Samagotchi
|
|
|
232
235
|
["GET", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_list, :owner, :session],
|
|
233
236
|
["POST", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_invite, :owner, :session],
|
|
234
237
|
["DELETE", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_revoke, :owner, :session],
|
|
238
|
+
["POST", %r{\A/api/sessions/([^/]+)/participants/([^/]+)/cap\z}, :handle_participants_cap, :owner, :session],
|
|
235
239
|
["GET", %r{\A/api/participants\z}, :handle_participants_list, :owner],
|
|
236
240
|
["DELETE", %r{\A/api/participants\z}, :handle_participants_revoke, :owner],
|
|
237
241
|
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
|
|
@@ -262,7 +266,7 @@ module Samagotchi
|
|
|
262
266
|
return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
|
|
263
267
|
end
|
|
264
268
|
|
|
265
|
-
return session_route { send(handler, req, *m.captures) } if kind == :session
|
|
269
|
+
return session_route(principal) { send(handler, req, *m.captures) } if kind == :session
|
|
266
270
|
|
|
267
271
|
return send(handler, req, *m.captures)
|
|
268
272
|
end
|
|
@@ -271,17 +275,24 @@ module Samagotchi
|
|
|
271
275
|
serve_static(req)
|
|
272
276
|
rescue StandardError => e
|
|
273
277
|
Log.exception(:web, "request_failed", e, method: env["REQUEST_METHOD"], path: env["PATH_INFO"])
|
|
274
|
-
|
|
278
|
+
# A participant never reads an exception's words (they may name a
|
|
279
|
+
# host path): the log has them.
|
|
280
|
+
error_response(500, "internal_error", owner_env?(env) ? e.message : "chi web failed on this request")
|
|
275
281
|
end
|
|
276
282
|
|
|
283
|
+
# Whether the request's principal is the owner (none yet: the gate
|
|
284
|
+
# didn't pass, so not a participant either).
|
|
285
|
+
def owner_env?(env) = env[PRINCIPAL_ENV].nil? || env[PRINCIPAL_ENV].owner?
|
|
286
|
+
|
|
277
287
|
# A :session route's handler: the ArgumentError its session lookup
|
|
278
288
|
# raises (Session.load, the manager's find) is a 404 not_found with
|
|
279
289
|
# its message. Only these routes: elsewhere an ArgumentError is a bug,
|
|
280
290
|
# #route's 500. A handler's own rescues run first.
|
|
281
|
-
def session_route
|
|
291
|
+
def session_route(principal = nil)
|
|
282
292
|
yield
|
|
283
293
|
rescue ArgumentError => e
|
|
284
|
-
|
|
294
|
+
# A participant's 404 never carries the lookup's words (a path).
|
|
295
|
+
error_response(404, "not_found", principal.nil? || principal.owner? ? e.message : "Session not found")
|
|
285
296
|
end
|
|
286
297
|
|
|
287
298
|
# API requests at debug level (not the page and its assets): method,
|
|
@@ -1517,6 +1528,9 @@ module Samagotchi
|
|
|
1517
1528
|
unless body.is_a?(Hash)
|
|
1518
1529
|
return error_response(400, "invalid_json", "invalid JSON body")
|
|
1519
1530
|
end
|
|
1531
|
+
# A participant's review turn: its own path, which takes from the
|
|
1532
|
+
# body only the prompt (ParticipantAccess#handle_participant_turn).
|
|
1533
|
+
return handle_participant_turn(req, id, body) unless principal(req).owner?
|
|
1520
1534
|
|
|
1521
1535
|
prompt = body["prompt"] || body[:prompt]
|
|
1522
1536
|
client_id = body["client_id"] || body[:client_id]
|
|
@@ -1698,6 +1712,13 @@ module Samagotchi
|
|
|
1698
1712
|
@session_class.load(id, state_dir: default_state_dir)
|
|
1699
1713
|
parsed = parse_json(request_body(req))
|
|
1700
1714
|
client_id = (parsed["client_id"] if parsed.is_a?(Hash))
|
|
1715
|
+
# A participant takes back only their own queued turns, by chi web's
|
|
1716
|
+
# record of who sent each (never a body's client id).
|
|
1717
|
+
unless (who = principal(req)).owner?
|
|
1718
|
+
return not_permitted_for(req, who) unless own_turn?(who.grant, id, enqueued_id)
|
|
1719
|
+
|
|
1720
|
+
client_id = ClientId.review(who.grant.id)
|
|
1721
|
+
end
|
|
1701
1722
|
|
|
1702
1723
|
request = ->(client) { client.dequeue(enqueued_id: enqueued_id, client_id: client_id) }
|
|
1703
1724
|
relay(id, bridge_client(id), request, what: "the message was not removed", cant: "remove queued messages") do |reply|
|
|
@@ -2037,18 +2058,20 @@ module Samagotchi
|
|
|
2037
2058
|
# the session's Bridge stream, through the request principal's frame
|
|
2038
2059
|
# filter (#stream_filter).
|
|
2039
2060
|
def proxy_stream_body(id, req, port)
|
|
2040
|
-
|
|
2041
|
-
|
|
2061
|
+
query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
|
|
2062
|
+
ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
|
|
2063
|
+
server_running: stream_running(principal(req)), filter: stream_filter(req, id))
|
|
2042
2064
|
end
|
|
2043
2065
|
|
|
2044
|
-
# What a
|
|
2045
|
-
# nil, the raw bytes unchanged, for the owner
|
|
2046
|
-
#
|
|
2047
|
-
#
|
|
2048
|
-
|
|
2049
|
-
|
|
2066
|
+
# What a request's stream goes through on its way from the Bridge:
|
|
2067
|
+
# nil, the raw bytes unchanged, for the owner; a participant's (and
|
|
2068
|
+
# the owner's ?as=participant, the participant view) goes through
|
|
2069
|
+
# ParticipantStreamFilter. A filter is anything with #call(chunk) ->
|
|
2070
|
+
# String (nil or "" sends nothing); see ProxyStreamBody.
|
|
2071
|
+
def stream_filter(req, id)
|
|
2072
|
+
return nil unless participant_view?(req)
|
|
2050
2073
|
|
|
2051
|
-
|
|
2074
|
+
participant_stream_filter(req, id)
|
|
2052
2075
|
end
|
|
2053
2076
|
|
|
2054
2077
|
# GET /api/events: the session list as one SSE stream per tab. The
|
|
@@ -2169,7 +2192,7 @@ module Samagotchi
|
|
|
2169
2192
|
end
|
|
2170
2193
|
|
|
2171
2194
|
def serve_index(req)
|
|
2172
|
-
return
|
|
2195
|
+
return participant_shell(req) if participant_view?(req)
|
|
2173
2196
|
|
|
2174
2197
|
path = File.join(@public_dir, "index.html")
|
|
2175
2198
|
if File.file?(path)
|
|
@@ -2467,6 +2490,8 @@ module Samagotchi
|
|
|
2467
2490
|
# record by this, not by its place.
|
|
2468
2491
|
turn_id = m[:turn_id] || m["turn_id"]
|
|
2469
2492
|
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
2493
|
+
# A participant's message names them (the page's author badge).
|
|
2494
|
+
message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"])) if norm_role == "user"
|
|
2470
2495
|
images = m[:images] || m["images"]
|
|
2471
2496
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
2472
2497
|
if norm_role == "assistant"
|
|
@@ -7,6 +7,8 @@ require "securerandom"
|
|
|
7
7
|
require "time"
|
|
8
8
|
require "rack/utils"
|
|
9
9
|
require_relative "../atomic_file"
|
|
10
|
+
require_relative "../config"
|
|
11
|
+
require_relative "../participant_name"
|
|
10
12
|
require_relative "../session"
|
|
11
13
|
require_relative "../redacted_inspect"
|
|
12
14
|
|
|
@@ -48,8 +50,19 @@ module Samagotchi
|
|
|
48
50
|
KINDS = %w[human agent].freeze
|
|
49
51
|
# The only role in v1: a reviewer reads and (P3) comments.
|
|
50
52
|
ROLES = %w[reviewer].freeze
|
|
51
|
-
NAME_MAX =
|
|
53
|
+
NAME_MAX = ParticipantName::MAX
|
|
52
54
|
ID_RE = /\Ap_[0-9a-f]{8}\z/
|
|
55
|
+
# D7: what a participant's turns (and the side calls they cause) may
|
|
56
|
+
# cost the owner, in dollars, unless chi share --cap says otherwise
|
|
57
|
+
# (config web.participants.cap); at most MAX_CAP.
|
|
58
|
+
DEFAULT_CAP = 2.0
|
|
59
|
+
MAX_CAP = 1000.0
|
|
60
|
+
# How many of a participant's turns may wait behind their running one
|
|
61
|
+
# (config web.participants.queue); at most MAX_QUEUE.
|
|
62
|
+
DEFAULT_QUEUE = 3
|
|
63
|
+
MAX_QUEUE = 20
|
|
64
|
+
# chi share --expires and config web.participants.expires: Nh or Nd.
|
|
65
|
+
EXPIRES_RE = /\A(\d+)([hd])\z/
|
|
53
66
|
|
|
54
67
|
# The store's folder or file can be read by others (chi web refuses to
|
|
55
68
|
# start), or a grant can't be made from what was given.
|
|
@@ -62,12 +75,23 @@ module Samagotchi
|
|
|
62
75
|
# participant too: a turn they send is authored "p:<id>" (P3).
|
|
63
76
|
# +revoked_by+ why it was revoked: "owner", "archive", "delete",
|
|
64
77
|
# "gone" (the session was pruned).
|
|
78
|
+
# +cap+ (dollars) and +queue+ (turns waiting) as issued; nil in a
|
|
79
|
+
# grant from before them, which takes the config's (#spend_cap,
|
|
80
|
+
# #queue_limit).
|
|
65
81
|
Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
66
|
-
:revoked_by, :last_used_at) do
|
|
82
|
+
:revoked_by, :last_used_at, :cap, :queue) do
|
|
83
|
+
def initialize(cap: nil, queue: nil, **) = super
|
|
84
|
+
|
|
67
85
|
def self.from_h(hash)
|
|
68
86
|
new(**members.to_h { |key| [key, hash[key.to_s]] })
|
|
69
87
|
end
|
|
70
88
|
|
|
89
|
+
# The dollars this participant may cost the owner.
|
|
90
|
+
def spend_cap = cap.is_a?(Numeric) ? cap.to_f : Grants.default_cap
|
|
91
|
+
|
|
92
|
+
# How many of their turns may wait behind their running one.
|
|
93
|
+
def queue_limit = queue.is_a?(Integer) ? queue : Grants.default_queue
|
|
94
|
+
|
|
71
95
|
# The author a participant's turn carries (Principal#author).
|
|
72
96
|
def author = "p:#{id}"
|
|
73
97
|
|
|
@@ -93,7 +117,7 @@ module Samagotchi
|
|
|
93
117
|
def public_h(now)
|
|
94
118
|
{ id: id, session_id: session_id, kind: kind, name: name, role: role, created_at: created_at,
|
|
95
119
|
expires_at: expires_at, revoked_at: revoked_at, revoked_by: revoked_by, last_used_at: last_used_at,
|
|
96
|
-
state: state(now).to_s }
|
|
120
|
+
state: state(now).to_s, cap: spend_cap, queue: queue_limit }
|
|
97
121
|
end
|
|
98
122
|
|
|
99
123
|
def to_store = to_h.transform_keys(&:to_s)
|
|
@@ -117,13 +141,45 @@ module Samagotchi
|
|
|
117
141
|
|
|
118
142
|
def self.digest(token) = Digest::SHA256.hexdigest(token.to_s)
|
|
119
143
|
|
|
144
|
+
# Seconds for +words+ (Nh or Nd, 1 hour to MAX_TTL), nil for none,
|
|
145
|
+
# false for words that aren't that.
|
|
146
|
+
def self.parse_ttl(words)
|
|
147
|
+
return nil if words.nil?
|
|
148
|
+
|
|
149
|
+
match = EXPIRES_RE.match(words.to_s.strip) or return false
|
|
150
|
+
seconds = match[1].to_i * (match[2] == "h" ? 3600 : DAY)
|
|
151
|
+
seconds.between?(3600, MAX_TTL) ? seconds : false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A new grant's lifetime: config web.participants.expires, else
|
|
155
|
+
# DEFAULT_TTL (a value that isn't Nh/Nd in range too).
|
|
156
|
+
def self.default_ttl
|
|
157
|
+
parse_ttl(Config.get("web.participants.expires")) || DEFAULT_TTL
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A grant's cap without its own: config web.participants.cap, else
|
|
161
|
+
# DEFAULT_CAP (a value out of range too).
|
|
162
|
+
def self.default_cap
|
|
163
|
+
value = Config.get("web.participants.cap")
|
|
164
|
+
cap?(value) ? value.to_f : DEFAULT_CAP
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A grant's queue limit without its own: config
|
|
168
|
+
# web.participants.queue, else DEFAULT_QUEUE.
|
|
169
|
+
def self.default_queue
|
|
170
|
+
value = Config.get("web.participants.queue")
|
|
171
|
+
value.is_a?(Integer) && value.between?(0, MAX_QUEUE) ? value : DEFAULT_QUEUE
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.cap?(value) = value.is_a?(Numeric) && value.to_f.finite? && value.to_f.between?(0, MAX_CAP)
|
|
175
|
+
|
|
120
176
|
def self.token?(value) = value.is_a?(String) && value.start_with?(PREFIX)
|
|
121
177
|
|
|
122
178
|
# One line, at most NAME_MAX characters, no brackets (a name can't
|
|
123
|
-
# forge
|
|
179
|
+
# forge the "[Review contribution from …]" header; ParticipantName).
|
|
124
180
|
# @raise [Invalid] nothing left
|
|
125
181
|
def self.clean_name(name)
|
|
126
|
-
clean =
|
|
182
|
+
clean = ParticipantName.clean(name)
|
|
127
183
|
raise Invalid, "a participant needs a name" if clean.empty?
|
|
128
184
|
|
|
129
185
|
clean
|
|
@@ -161,14 +217,18 @@ module Samagotchi
|
|
|
161
217
|
def now = @clock.call
|
|
162
218
|
|
|
163
219
|
# A new grant for +session_id+, written before the token is handed out.
|
|
164
|
-
# @param ttl [Integer] seconds (
|
|
220
|
+
# @param ttl [Integer, nil] seconds (nil: Grants.default_ttl; at most MAX_TTL)
|
|
221
|
+
# @param cap [Numeric, nil] dollars (nil: Grants.default_cap)
|
|
165
222
|
# @return [Issued]
|
|
166
223
|
# @raise [Invalid]
|
|
167
|
-
def issue(session_id:, name:, kind: "human", role: "reviewer", ttl:
|
|
224
|
+
def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: nil, cap: nil)
|
|
168
225
|
Session.check_id!(session_id)
|
|
226
|
+
ttl ||= Grants.default_ttl
|
|
227
|
+
cap = Grants.default_cap if cap.nil?
|
|
169
228
|
raise Invalid, "kind is human or agent, not #{kind}" unless KINDS.include?(kind)
|
|
170
229
|
raise Invalid, "role is reviewer, not #{role}" unless ROLES.include?(role)
|
|
171
230
|
raise Invalid, "a grant lasts 1 hour to 30 days" unless ttl.is_a?(Integer) && ttl.between?(3600, MAX_TTL)
|
|
231
|
+
raise Invalid, CAP_RANGE unless Grants.cap?(cap)
|
|
172
232
|
|
|
173
233
|
clean = Grants.clean_name(name)
|
|
174
234
|
token = "#{PREFIX}#{SecureRandom.urlsafe_base64(BYTES)}"
|
|
@@ -177,7 +237,7 @@ module Samagotchi
|
|
|
177
237
|
id = fresh_id(grants)
|
|
178
238
|
grant = Grant.new(id: id, session_id: session_id, digest: Grants.digest(token), kind: kind, name: clean,
|
|
179
239
|
role: role, created_at: stamp(issued), expires_at: stamp(issued + ttl), revoked_at: nil,
|
|
180
|
-
revoked_by: nil, last_used_at: nil)
|
|
240
|
+
revoked_by: nil, last_used_at: nil, cap: cap.to_f, queue: Grants.default_queue)
|
|
181
241
|
[grants + [grant], Issued.new(grant: grant, token: token)]
|
|
182
242
|
end
|
|
183
243
|
end
|
|
@@ -230,6 +290,27 @@ module Samagotchi
|
|
|
230
290
|
end
|
|
231
291
|
end
|
|
232
292
|
|
|
293
|
+
CAP_RANGE = "a cap is $0 to $#{MAX_CAP.to_i}".freeze
|
|
294
|
+
|
|
295
|
+
# Sets the active grant +id+'s cap to +cap+ dollars.
|
|
296
|
+
# @return [Grant, nil] the changed grant; nil when +id+ isn't an
|
|
297
|
+
# active grant (of +session_id+, when given)
|
|
298
|
+
# @raise [Invalid] a cap out of range
|
|
299
|
+
def set_cap(id, cap, session_id: nil)
|
|
300
|
+
raise Invalid, CAP_RANGE unless Grants.cap?(cap)
|
|
301
|
+
|
|
302
|
+
at = now
|
|
303
|
+
update do |grants|
|
|
304
|
+
changed = nil
|
|
305
|
+
grants = grants.map do |grant|
|
|
306
|
+
next grant unless grant.id == id && grant.active?(at) && (session_id.nil? || grant.session_id == session_id)
|
|
307
|
+
|
|
308
|
+
changed = grant.with(cap: cap.to_f)
|
|
309
|
+
end
|
|
310
|
+
[grants, changed]
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
233
314
|
# Records a use of grant +id+, at most once per TOUCH_INTERVAL.
|
|
234
315
|
def touch(id)
|
|
235
316
|
at = now
|