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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/cli.md +62 -19
  5. data/docs/configuration.md +10 -6
  6. data/docs/guardrails.md +69 -0
  7. data/docs/hooks.md +9 -0
  8. data/docs/internals/anthropic.md +2 -1
  9. data/docs/internals/engine.md +37 -1
  10. data/docs/internals/participants.md +229 -21
  11. data/docs/internals/prompt-caching.md +9 -0
  12. data/docs/internals/working-set.md +12 -5
  13. data/docs/plugins.md +2 -0
  14. data/docs/testing.md +23 -0
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -4
  16. data/lib/samagotchi/bridge.rb +45 -8
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/client_id.rb +21 -9
  20. data/lib/samagotchi/config.rb +17 -2
  21. data/lib/samagotchi/continue_offer.rb +13 -3
  22. data/lib/samagotchi/delivery.rb +10 -0
  23. data/lib/samagotchi/engine.rb +98 -8
  24. data/lib/samagotchi/guardrail_wiring.rb +13 -3
  25. data/lib/samagotchi/guardrails/review_boundary.rb +148 -0
  26. data/lib/samagotchi/guardrails.rb +1 -0
  27. data/lib/samagotchi/hook_routes.rb +5 -2
  28. data/lib/samagotchi/hooks/registry.rb +12 -3
  29. data/lib/samagotchi/idle_recap.rb +9 -0
  30. data/lib/samagotchi/last_turn.rb +5 -2
  31. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  32. data/lib/samagotchi/parent_report.rb +17 -5
  33. data/lib/samagotchi/participant_messages.rb +16 -0
  34. data/lib/samagotchi/participant_name.rb +21 -0
  35. data/lib/samagotchi/participants.rb +29 -6
  36. data/lib/samagotchi/participants_watch.rb +90 -0
  37. data/lib/samagotchi/plugin/context.rb +7 -1
  38. data/lib/samagotchi/plugin/side_question.rb +9 -1
  39. data/lib/samagotchi/queued_command.rb +9 -5
  40. data/lib/samagotchi/reply_wait/poll.rb +18 -3
  41. data/lib/samagotchi/run_options.rb +1 -1
  42. data/lib/samagotchi/send_command.rb +41 -3
  43. data/lib/samagotchi/session_access/contract.rb +2 -0
  44. data/lib/samagotchi/session_access/local.rb +31 -9
  45. data/lib/samagotchi/session_access/remote.rb +19 -2
  46. data/lib/samagotchi/session_access/web_bridge_client.rb +4 -2
  47. data/lib/samagotchi/session_access.rb +6 -3
  48. data/lib/samagotchi/session_commands.rb +6 -0
  49. data/lib/samagotchi/session_inbox.rb +31 -5
  50. data/lib/samagotchi/session_manager.rb +43 -3
  51. data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
  52. data/lib/samagotchi/session_metrics.rb +94 -1
  53. data/lib/samagotchi/session_participants.rb +59 -0
  54. data/lib/samagotchi/sessions_command.rb +10 -7
  55. data/lib/samagotchi/share_command.rb +61 -22
  56. data/lib/samagotchi/side_usage.rb +4 -2
  57. data/lib/samagotchi/steer.rb +120 -11
  58. data/lib/samagotchi/system_prompt.rb +21 -2
  59. data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +94 -22
  61. data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
  62. data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
  63. data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
  64. data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
  65. data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
  66. data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
  67. data/lib/samagotchi/terminal_ui.rb +5 -0
  68. data/lib/samagotchi/testing/host.rb +4 -2
  69. data/lib/samagotchi/testing/recorder.rb +3 -1
  70. data/lib/samagotchi/toolkit.rb +21 -3
  71. data/lib/samagotchi/turn_checkpoint.rb +5 -2
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +45 -20
  74. data/lib/samagotchi/web/grants.rb +89 -8
  75. data/lib/samagotchi/web/participant_access.rb +206 -35
  76. data/lib/samagotchi/web/participant_review.rb +165 -0
  77. data/lib/samagotchi/web/participant_stream_filter.rb +157 -0
  78. data/lib/samagotchi/web/participant_view.rb +415 -0
  79. data/lib/samagotchi/web/public/app.js +15 -1
  80. data/lib/samagotchi/web/public/bubbles.js +3 -3
  81. data/lib/samagotchi/web/public/data.js +20 -0
  82. data/lib/samagotchi/web/public/index.html +75 -30
  83. data/lib/samagotchi/web/public/info_bar.js +12 -5
  84. data/lib/samagotchi/web/public/info_bar_model.js +8 -5
  85. data/lib/samagotchi/web/public/participant.html +119 -0
  86. data/lib/samagotchi/web/public/participant.js +300 -0
  87. data/lib/samagotchi/web/public/participant_model.js +160 -0
  88. data/lib/samagotchi/web/public/participant_names.js +21 -0
  89. data/lib/samagotchi/web/public/participants_panel.js +311 -0
  90. data/lib/samagotchi/web/public/stream_handlers.js +8 -1
  91. data/lib/samagotchi/web/public/tokens.css +12 -0
  92. data/lib/samagotchi/web/public/turn_events.js +16 -5
  93. data/lib/samagotchi/web/public/turn_html.js +8 -4
  94. data/lib/samagotchi/web/public/turn_view.js +2 -1
  95. data/lib/samagotchi/web/public/working_set.js +6 -4
  96. data/lib/samagotchi/web/public/working_set_view.js +4 -4
  97. data/lib/samagotchi/web/qr.rb +14 -0
  98. data/lib/samagotchi/web/running.rb +52 -0
  99. data/lib/samagotchi/web/server.rb +13 -0
  100. data/lib/samagotchi/web/session_access_routes.rb +3 -0
  101. data/lib/samagotchi/worker.rb +125 -11
  102. data/lib/samagotchi/worker_inbound.rb +12 -1
  103. 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,
@@ -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) with a line in +errors+, and so is one
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 kits.key?(name)
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
- def begin_turn(prompt: nil, enqueued_id: nil)
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.104.0"
4
+ VERSION = "0.106.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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, :owner],
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, :owner],
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, :owner, :session],
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, :owner, :session],
211
- ["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :owner, :session],
212
- ["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :owner, :session],
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
- error_response(500, "internal_error", e.message)
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
- error_response(404, "not_found", e.message)
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
- ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: stream_query(req), headers: req.env,
2041
- server_running: stream_running(principal(req)), filter: stream_filter(principal(req)))
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 principal's stream goes through on its way from the Bridge:
2045
- # nil, the raw bytes unchanged, for the owner (the only principal
2046
- # today). A filter is anything with #call(chunk) -> String (nil or
2047
- # "" sends nothing); see ProxyStreamBody.
2048
- def stream_filter(principal)
2049
- return nil if principal.owner?
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
- raise ArgumentError, "no stream filter for a #{principal.kind} principal"
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 participant_page(principal(req)) unless principal(req).owner?
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 = 64
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 P3's "[Review contribution from …]" header).
179
+ # forge the "[Review contribution from …]" header; ParticipantName).
124
180
  # @raise [Invalid] nothing left
125
181
  def self.clean_name(name)
126
- clean = name.to_s.gsub(/[[:cntrl:]\[\]]/, " ").squeeze(" ").strip[0, NAME_MAX].to_s.strip
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 (DEFAULT_TTL; at most MAX_TTL)
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: DEFAULT_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