samagotchi 0.107.0 → 0.108.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/internals/engine.md +17 -6
  5. data/docs/internals/participants.md +5 -2
  6. data/docs/internals/working-set.md +2 -1
  7. data/docs/memory.md +20 -0
  8. data/docs/plugins.md +9 -2
  9. data/docs/releasing.md +11 -6
  10. data/docs/sessions.md +3 -3
  11. data/docs/sub-agent.md +1 -1
  12. data/docs/testing.md +5 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
  14. data/lib/samagotchi/bridge.rb +3 -2
  15. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  21. data/lib/samagotchi/child_reports.rb +2 -1
  22. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  23. data/lib/samagotchi/context_sources.rb +1 -6
  24. data/lib/samagotchi/continue_offer.rb +8 -6
  25. data/lib/samagotchi/engine.rb +36 -25
  26. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  27. data/lib/samagotchi/idle_scheduler.rb +17 -2
  28. data/lib/samagotchi/last_turn.rb +7 -0
  29. data/lib/samagotchi/log_subscriber.rb +2 -1
  30. data/lib/samagotchi/memory_orphans.rb +74 -0
  31. data/lib/samagotchi/memory_paths.rb +4 -4
  32. data/lib/samagotchi/parent_report.rb +11 -3
  33. data/lib/samagotchi/reply_wait.rb +7 -2
  34. data/lib/samagotchi/send_command/busy.rb +5 -2
  35. data/lib/samagotchi/send_command/wait_target.rb +16 -11
  36. data/lib/samagotchi/session_inbox.rb +12 -8
  37. data/lib/samagotchi/session_metrics.rb +7 -6
  38. data/lib/samagotchi/session_participants.rb +3 -1
  39. data/lib/samagotchi/steer.rb +11 -1
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
  41. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  42. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  43. data/lib/samagotchi/tools/memory.rb +16 -9
  44. data/lib/samagotchi/turn_origin.rb +68 -0
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +2 -0
  47. data/lib/samagotchi/web/grants.rb +2 -1
  48. data/lib/samagotchi/web/participant_access.rb +6 -116
  49. data/lib/samagotchi/web/participant_view.rb +7 -13
  50. data/lib/samagotchi/web/participants_admin.rb +129 -0
  51. data/lib/samagotchi/web/public/participant.js +2 -2
  52. data/lib/samagotchi/web/public/participant_model.js +5 -0
  53. data/lib/samagotchi/web/public/participant_names.js +2 -2
  54. data/lib/samagotchi/web/public/participants_panel.js +2 -2
  55. data/lib/samagotchi/worker.rb +30 -25
  56. data/lib/samagotchi/worker_inbound.rb +12 -9
  57. data/lib/samagotchi/working_set.rb +3 -2
  58. metadata +4 -1
@@ -4,15 +4,12 @@ require "json"
4
4
  require_relative "grants"
5
5
  require_relative "principal"
6
6
  require_relative "participant_view"
7
- require_relative "qr"
8
7
  require_relative "../archive_store"
9
8
  require_relative "../client_id"
10
9
  require_relative "../delivery"
11
10
  require_relative "../session_inbox"
12
11
  require_relative "../session_metrics"
13
12
  require_relative "../parent_report"
14
- require_relative "../review_start"
15
- require_relative "../review_apply"
16
13
  require_relative "../participant_messages"
17
14
  require_relative "../participants"
18
15
  require_relative "../session"
@@ -22,10 +19,12 @@ module Samagotchi
22
19
  module Web
23
20
  # What a participant (a Web::Grants token) may do in chi web, mixed into
24
21
  # App: the gate's lookup of their grant, the refusals that are theirs
25
- # (token_revoked, token_expired, another session's 404, not_permitted
26
- # by role), the trimmed answers of the :participant routes, their turns,
27
- # and the running check that ends their streams when the grant does
28
- # (what they read is ParticipantReview's). See
22
+ # (token_revoked, token_expired with its ended page, another session's
23
+ # 404, not_permitted by role), the trimmed answers of their own routes
24
+ # (info, the session, resolve, a wait on their turn), their turns, and
25
+ # the running check that ends their streams when the grant does. What
26
+ # they read is ParticipantReview's; the owner's routes for them (invite,
27
+ # list, revoke, cap, Apply) are ParticipantsAdmin's. See
29
28
  # docs/internals/participants.md.
30
29
  module ParticipantAccess
31
30
  # A participant's refused routes are audited at most this often per
@@ -376,115 +375,6 @@ module Samagotchi
376
375
  -> { @server_running.call && @grants.active?(id) }
377
376
  end
378
377
 
379
- # ── The owner's routes ─────────────────────────────────────────────
380
-
381
- # GET /api/sessions/:id/participants, GET /api/participants: the
382
- # grants (one session's, or every one), in every state, never a token
383
- # or digest. One session's also carries +warning+: the share warning
384
- # for a session that isn't a review session (ReviewStart.share_warning,
385
- # null for one), which the panel shows before its Invite.
386
- def handle_participants_list(_req, id = nil)
387
- now = @grants.now
388
- grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
389
- return json_response(200, { participants: grants }) unless id
390
-
391
- warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
392
- json_response(200, { participants: with_spend(id, grants), warning: warning })
393
- end
394
-
395
- # One session's grants with what each spent and how many turns they
396
- # ran (its saved analytics: SessionMetrics.saved_by_author), for the
397
- # owner's participants panel.
398
- def with_spend(id, grants)
399
- rows = SessionMetrics.saved_by_author(Session.session_dir(id, state_dir: default_state_dir))
400
- grants.map do |grant|
401
- row = rows["p:#{grant[:id]}"] || { turns: 0, cost: 0.0 }
402
- grant.merge(spend: row[:cost].round(4), turns: row[:turns])
403
- end
404
- end
405
-
406
- # POST /api/sessions/:id/participants {name, kind?, ttl? (seconds),
407
- # cap? (dollars)}: a new grant. The answer carries the token and the
408
- # link once (chi keeps only its digest), and +warning+ for a session
409
- # that isn't a review session (ReviewStart.share_warning; null for one).
410
- def handle_participants_invite(req, id)
411
- body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
412
- ttl = body["ttl"]
413
- return error_response(400, "invalid_participant", "ttl is a number of seconds") unless ttl.nil? || ttl.is_a?(Integer)
414
-
415
- cap = body["cap"]
416
- return error_response(400, "invalid_participant", Grants::CAP_RANGE) unless cap.nil? || Grants.cap?(cap)
417
-
418
- issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
419
- ttl: ttl, cap: cap, state_dir: default_state_dir)
420
- link = participant_link(req, id, issued.token)
421
- warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
422
- json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
423
- link: link, lan: !@lan.nil?, qr_svg: QR.svg(link), warning: warning })
424
- rescue Grants::Invalid => e
425
- error_response(400, "invalid_participant", e.message)
426
- end
427
-
428
- # GET /api/sessions/:id/apply[?turn=T] (owner): the owner's turn that
429
- # applies review turn T's proposal (ReviewApply: the reply quoted
430
- # under one line of the owner's), for the composer: never sent from
431
- # here. Without turn, the latest review turn with a reply. Read from
432
- # the live worker's messages, else the saved ones.
433
- # @return 200 {text, turn_id, author, author_name}; 404 not_found (no
434
- # such turn, or no review turn); 400 not_review_turn; 409 no_reply
435
- def handle_apply(req, id)
436
- session = @session_class.load(id, state_dir: default_state_dir)
437
- live = bridge_get_json(id, "snapshot")
438
- messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
439
- proposal = ReviewApply.find(messages, req.params["turn"])
440
- json_response(200, { text: ReviewApply.compose(proposal), turn_id: proposal.turn_id, author: proposal.author,
441
- author_name: proposal.author_name })
442
- rescue ReviewApply::Refused => e
443
- status = { not_found: 404, not_review_turn: 400, no_reply: 409 }.fetch(e.code)
444
- error_response(status, e.code.to_s, e.message)
445
- end
446
-
447
- # DELETE /api/sessions/:id/participants[?id=p_…], DELETE
448
- # /api/participants: revokes one participant, a session's or every
449
- # one's; their open streams end within a poll.
450
- def handle_participants_revoke(req, id = nil)
451
- grant_id = req.params["id"]
452
- if grant_id && !Grants::ID_RE.match?(grant_id.to_s)
453
- return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
454
- end
455
-
456
- revoked = Participants.revoke(@grants, session_id: id, id: grant_id, by: "owner", state_dir: default_state_dir)
457
- now = @grants.now
458
- json_response(200, { revoked: revoked.map { |grant| grant.public_h(now) } })
459
- end
460
-
461
- # POST /api/sessions/:id/participants/:participant/cap {cap}: the
462
- # active participant's new spending cap (dollars), from their next
463
- # turn on. 200 {participant}; 400 invalid_participant.
464
- def handle_participants_cap(req, id, grant_id)
465
- body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
466
- unless Grants::ID_RE.match?(grant_id.to_s)
467
- return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
468
- end
469
-
470
- grant = Participants.set_cap(@grants, session_id: id, id: grant_id, cap: body["cap"], state_dir: default_state_dir)
471
- return error_response(400, "invalid_participant", Participants.no_participant(grant_id, id)) unless grant
472
-
473
- json_response(200, { participant: grant.public_h(@grants.now) })
474
- rescue Grants::Invalid => e
475
- error_response(400, "invalid_participant", e.message)
476
- end
477
-
478
- # The link a participant opens: this chi web's LAN address when it has
479
- # one (the owner may be asking on loopback), else the Host asked; the
480
- # token traded for the cookie, the session in the fragment.
481
- def participant_link(req, id, token)
482
- host = req.get_header("HTTP_HOST").to_s
483
- port = host[/:(\d+)\z/, 1] || req.port.to_s
484
- name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
485
- "http://#{name}:#{port}/?token=#{token}#s=#{id}"
486
- end
487
-
488
378
  def html_response(status, body)
489
379
  [status, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s,
490
380
  "Cache-Control" => "no-store" }, [body]]
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../client_id"
4
3
  require_relative "../output_formatter"
5
4
  require_relative "../steer"
5
+ require_relative "../turn_origin"
6
6
 
7
7
  module Samagotchi
8
8
  module Web
@@ -102,17 +102,18 @@ module Samagotchi
102
102
 
103
103
  # A turn's start: who wrote it, and its prompt when a person sent it
104
104
  # (an automatic turn's prompt, a reminder's or a task's, is not
105
- # shown). +mine+ when the viewer queued it.
105
+ # shown: a person is the owner's web or TUI, chi send, a participant,
106
+ # or no origin, an in-process turn: the owner's; TurnOrigin#human?).
107
+ # +mine+ when the viewer queued it.
106
108
  def turn_started(event, viewer)
107
- origin = field(event, :origin)
108
- origin = {} unless origin.is_a?(Hash)
109
- author = field(event, :author) || field(origin, :author)
109
+ origin = TurnOrigin.of(field(event, :origin))
110
+ author = field(event, :author) || origin.author
110
111
  out = { "type" => "turn_started", **id_fields(event) }
111
112
  out.merge!(author_fields(author, field(event, :author_name), viewer))
112
113
  prompt = field(event, :prompt)
113
114
  if field(event, :continue)
114
115
  out["continue"] = true
115
- elsif person_turn?(origin)
116
+ elsif origin.human?
116
117
  out["prompt"] = prompt.to_s unless prompt.nil?
117
118
  else
118
119
  out["automatic"] = true
@@ -286,13 +287,6 @@ module Samagotchi
286
287
  out
287
288
  end
288
289
 
289
- # Sent by a person (the owner's web or TUI, chi send, a participant),
290
- # or with no origin (an in-process turn: the owner's).
291
- def person_turn?(origin)
292
- client_id = field(origin, :client_id)
293
- client_id.nil? || ClientId.human?(client_id)
294
- end
295
-
296
290
  def id_fields(event)
297
291
  out = seq(event)
298
292
  turn_id = field(event, :turn_id)
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "grants"
4
+ require_relative "qr"
5
+ require_relative "../review_start"
6
+ require_relative "../review_apply"
7
+ require_relative "../session_metrics"
8
+ require_relative "../participants"
9
+ require_relative "../session"
10
+
11
+ module Samagotchi
12
+ module Web
13
+ # The owner's routes for a session's participants, mixed into App:
14
+ # invite, list, revoke, a participant's cap, and the review Apply (the
15
+ # owner's turn quoting a review turn's proposal). What a participant
16
+ # may do themselves is ParticipantAccess's. See
17
+ # docs/internals/participants.md.
18
+ module ParticipantsAdmin
19
+ private
20
+
21
+ # GET /api/sessions/:id/participants, GET /api/participants: the
22
+ # grants (one session's, or every one), in every state, never a token
23
+ # or digest. One session's also carries +warning+: the share warning
24
+ # for a session that isn't a review session (ReviewStart.share_warning,
25
+ # null for one), which the panel shows before its Invite.
26
+ def handle_participants_list(_req, id = nil)
27
+ now = @grants.now
28
+ grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
29
+ return json_response(200, { participants: grants }) unless id
30
+
31
+ warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
32
+ json_response(200, { participants: with_spend(id, grants), warning: warning })
33
+ end
34
+
35
+ # One session's grants with what each spent and how many turns they
36
+ # ran (its saved analytics: SessionMetrics.saved_by_author), for the
37
+ # owner's participants panel.
38
+ def with_spend(id, grants)
39
+ rows = SessionMetrics.saved_by_author(Session.session_dir(id, state_dir: default_state_dir))
40
+ grants.map do |grant|
41
+ row = rows[Steer.author_for(grant[:id])] || { turns: 0, cost: 0.0 }
42
+ grant.merge(spend: row[:cost].round(4), turns: row[:turns])
43
+ end
44
+ end
45
+
46
+ # POST /api/sessions/:id/participants {name, kind?, ttl? (seconds),
47
+ # cap? (dollars)}: a new grant. The answer carries the token and the
48
+ # link once (chi keeps only its digest), and +warning+ for a session
49
+ # that isn't a review session (ReviewStart.share_warning; null for one).
50
+ def handle_participants_invite(req, id)
51
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
52
+ ttl = body["ttl"]
53
+ return error_response(400, "invalid_participant", "ttl is a number of seconds") unless ttl.nil? || ttl.is_a?(Integer)
54
+
55
+ cap = body["cap"]
56
+ return error_response(400, "invalid_participant", Grants::CAP_RANGE) unless cap.nil? || Grants.cap?(cap)
57
+
58
+ issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
59
+ ttl: ttl, cap: cap, state_dir: default_state_dir)
60
+ link = participant_link(req, id, issued.token)
61
+ warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
62
+ json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
63
+ link: link, lan: !@lan.nil?, qr_svg: QR.svg(link), warning: warning })
64
+ rescue Grants::Invalid => e
65
+ error_response(400, "invalid_participant", e.message)
66
+ end
67
+
68
+ # GET /api/sessions/:id/apply[?turn=T] (owner): the owner's turn that
69
+ # applies review turn T's proposal (ReviewApply: the reply quoted
70
+ # under one line of the owner's), for the composer: never sent from
71
+ # here. Without turn, the latest review turn with a reply. Read from
72
+ # the live worker's messages, else the saved ones.
73
+ # @return 200 {text, turn_id, author, author_name}; 404 not_found (no
74
+ # such turn, or no review turn); 400 not_review_turn; 409 no_reply
75
+ def handle_apply(req, id)
76
+ session = @session_class.load(id, state_dir: default_state_dir)
77
+ live = bridge_get_json(id, "snapshot")
78
+ messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
79
+ proposal = ReviewApply.find(messages, req.params["turn"])
80
+ json_response(200, { text: ReviewApply.compose(proposal), turn_id: proposal.turn_id, author: proposal.author,
81
+ author_name: proposal.author_name })
82
+ rescue ReviewApply::Refused => e
83
+ status = { not_found: 404, not_review_turn: 400, no_reply: 409 }.fetch(e.code)
84
+ error_response(status, e.code.to_s, e.message)
85
+ end
86
+
87
+ # DELETE /api/sessions/:id/participants[?id=p_…], DELETE
88
+ # /api/participants: revokes one participant, a session's or every
89
+ # one's; their open streams end within a poll.
90
+ def handle_participants_revoke(req, id = nil)
91
+ grant_id = req.params["id"]
92
+ if grant_id && !Grants::ID_RE.match?(grant_id.to_s)
93
+ return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
94
+ end
95
+
96
+ revoked = Participants.revoke(@grants, session_id: id, id: grant_id, by: "owner", state_dir: default_state_dir)
97
+ now = @grants.now
98
+ json_response(200, { revoked: revoked.map { |grant| grant.public_h(now) } })
99
+ end
100
+
101
+ # POST /api/sessions/:id/participants/:participant/cap {cap}: the
102
+ # active participant's new spending cap (dollars), from their next
103
+ # turn on. 200 {participant}; 400 invalid_participant.
104
+ def handle_participants_cap(req, id, grant_id)
105
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
106
+ unless Grants::ID_RE.match?(grant_id.to_s)
107
+ return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
108
+ end
109
+
110
+ grant = Participants.set_cap(@grants, session_id: id, id: grant_id, cap: body["cap"], state_dir: default_state_dir)
111
+ return error_response(400, "invalid_participant", Participants.no_participant(grant_id, id)) unless grant
112
+
113
+ json_response(200, { participant: grant.public_h(@grants.now) })
114
+ rescue Grants::Invalid => e
115
+ error_response(400, "invalid_participant", e.message)
116
+ end
117
+
118
+ # The link a participant opens: this chi web's LAN address when it has
119
+ # one (the owner may be asking on loopback), else the Host asked; the
120
+ # token traded for the cookie, the session in the fragment.
121
+ def participant_link(req, id, token)
122
+ host = req.get_header("HTTP_HOST").to_s
123
+ port = host[/:(\d+)\z/, 1] || req.port.to_s
124
+ name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
125
+ "http://#{name}:#{port}/?token=#{token}#s=#{id}"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -6,7 +6,7 @@
6
6
  // progress from the stream in between. A participant's comment is
7
7
  // POST …/turn (text only); their queued ones come back with Dequeue.
8
8
  import {
9
- authorColor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
9
+ authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
10
10
  liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries,
11
11
  } from "./participant_model.js";
12
12
 
@@ -160,7 +160,7 @@ function render() {
160
160
  status.className = `p-status ${waiting ? "waiting" : session.status === "running" ? "running" : ""}`;
161
161
  $("people").replaceChildren(...(data.participants || []).map((p) => {
162
162
  const chip = el("span", "p-person");
163
- chip.style.setProperty("--author-color", authorColor(`p:${p.id}`));
163
+ chip.style.setProperty("--author-color", authorColor(participantAuthor(p.id)));
164
164
  chip.append(el("span", "p-dot"), `${p.name}${p.kind === "agent" ? " (agent)" : ""}`);
165
165
  return chip;
166
166
  }));
@@ -3,6 +3,11 @@
3
3
  // refusal in words, the stream's URL, and the live turn as filtered events
4
4
  // build it (ParticipantView on the server decides what arrives at all).
5
5
 
6
+ // A participant's author: "p:" and its grant id, as the server writes it
7
+ // (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
8
+ export const PARTICIPANT_PREFIX = "p:";
9
+ export const participantAuthor = (id) => `${PARTICIPANT_PREFIX}${id}`;
10
+
6
11
  // Six author colours (tokens.css --author-1..6), one per participant id,
7
12
  // the same on every page that shows them (the owner's badges too).
8
13
  export const AUTHOR_SLOTS = 6;
@@ -4,7 +4,7 @@
4
4
  // prompt's own author_name (a saved message, turn_started) comes first; a
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.
7
- import { authorColor } from "./participant_model.js";
7
+ import { authorColor, participantAuthor } from "./participant_model.js";
8
8
 
9
9
  export const UNNAMED = "a participant";
10
10
 
@@ -13,7 +13,7 @@ let names = new Map();
13
13
  // @return whether the names changed (the panel then names the badges drawn
14
14
  // without them)
15
15
  export function setParticipantNames(grants) {
16
- const next = new Map((grants || []).filter((g) => g?.id).map((g) => [`p:${g.id}`, String(g.name || "")]));
16
+ const next = new Map((grants || []).filter((g) => g?.id).map((g) => [participantAuthor(g.id), String(g.name || "")]));
17
17
  const changed = next.size !== names.size || [...next].some(([author, name]) => names.get(author) !== name);
18
18
  names = next;
19
19
  return changed;
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { html, nothing, render } from "./vendor/lit-html.js";
16
16
  import { createChipPopover } from "./chip_popover.js";
17
- import { authorColor } from "./participant_model.js";
17
+ import { authorColor, participantAuthor } from "./participant_model.js";
18
18
  import { setParticipantNames } from "./participant_names.js";
19
19
 
20
20
  // Seen this recently: here now (the chip's dot). The gate writes
@@ -63,7 +63,7 @@ export function rowModel(grant, now) {
63
63
  kind: grant.kind,
64
64
  active,
65
65
  present,
66
- color: authorColor(`p:${grant.id}`),
66
+ color: authorColor(participantAuthor(grant.id)),
67
67
  state: active ? (present ? "here now" : `seen ${agoText(grant.last_used_at, now)}`)
68
68
  : grant.state === "expired" ? "expired" : `revoked${grant.revoked_by ? ` (${grant.revoked_by})` : ""}`,
69
69
  expires: active ? `expires ${untilText(grant.expires_at, now)}` : "",
@@ -11,6 +11,7 @@ require_relative "session"
11
11
  require_relative "session_inbox"
12
12
  require_relative "turn_note"
13
13
  require_relative "steer"
14
+ require_relative "turn_origin"
14
15
  require_relative "worker_idle_exit"
15
16
  require_relative "worker_wakes"
16
17
  require_relative "worker_inbound"
@@ -145,7 +146,7 @@ module Samagotchi
145
146
  @discard = false
146
147
  # The exit asked for is a restart (POST /exit restart: true).
147
148
  @exit_restart = false
148
- # The lines the running turn's drain merged ([prompt, origin]), for a
149
+ # The lines the running turn's drain merged ([prompt, TurnOrigin]), for a
149
150
  # failed prompt turn to hand back; reset as each turn begins.
150
151
  @merged_this_turn = []
151
152
  # A delegate child's side (ChildRing): whether the running turn is
@@ -624,7 +625,7 @@ module Samagotchi
624
625
  # A participant's turn queued before their link ended or their cap was
625
626
  # reached doesn't start (chi web refuses new ones; this is the
626
627
  # worker's check).
627
- if (reason = participant_refusal(origin&.dig(:author)))
628
+ if (reason = participant_refusal(origin.author))
628
629
  return refuse_participant_turn(origin, reason)
629
630
  end
630
631
 
@@ -632,7 +633,7 @@ module Samagotchi
632
633
  # background tasks that finished since the last look.
633
634
  absorb_context(before_turn: true)
634
635
  @task_wakes.absorb
635
- user_input(origin&.dig(:client_id))
636
+ user_input(origin.client_id)
636
637
  @continue_offer.drop(origin)
637
638
  @turn_flow.before_prompt_turn
638
639
  run_engine_turn(prompt.text, origin: origin, max_iterations: max_iterations(prompt.no_interrupt),
@@ -644,7 +645,7 @@ module Samagotchi
644
645
  @continue_offer.after_turn(result, no_interrupt: prompt.no_interrupt)
645
646
  end
646
647
  end
647
- participant_refusal(origin&.dig(:author))
648
+ participant_refusal(origin.author)
648
649
  end
649
650
 
650
651
  # Why the participant +author+ ("p:<id>") may run no more turns, nil
@@ -656,7 +657,7 @@ module Samagotchi
656
657
  def participant_refusal(author)
657
658
  author = Steer.sender_author(author) or return nil
658
659
 
659
- entry = Participants.read(@session_id, state_dir: @state_dir).find { |e| "p:#{e.id}" == author }
660
+ entry = Participants.read(@session_id, state_dir: @state_dir).find { |e| Steer.author_for(e.id) == author }
660
661
  return "revoked" unless entry&.active?(Time.now)
661
662
 
662
663
  cap = entry.cap.is_a?(Numeric) ? entry.cap.to_f : Web::Grants.default_cap
@@ -673,7 +674,7 @@ module Samagotchi
673
674
  return if (@cap_noticed ||= Set.new).include?(key)
674
675
 
675
676
  @cap_noticed << key
676
- id = author.delete_prefix("p:")
677
+ id = Steer.participant_id(author)
677
678
  who = ParticipantName.clean(name)
678
679
  @engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
679
680
  text: format(CAP_NOTICE, name: who.empty? ? id : who, cap: cap, session: @session_id[0, 8], id: id))
@@ -686,11 +687,12 @@ module Samagotchi
686
687
  # taken off the queue (:turn_dequeued, so every UI drops its bubble)
687
688
  # and audited; it never runs. Its settled record (canceled, the reason)
688
689
  # ends the sender's wait on it (chi send --wait, chi web's /wait).
690
+ # @param origin [TurnOrigin] a participant's
689
691
  def refuse_participant_turn(origin, reason)
690
- TurnCheckpoint.record_refused(@session_dir, origin[:enqueued_id], reason: reason)
691
- Participants.log(@session_id, :turn, state_dir: @state_dir, participant: origin[:author].delete_prefix("p:"),
692
- enqueued_id: origin[:enqueued_id], status: "refused", reason: reason)
693
- @engine.announce(type: :turn_dequeued, enqueued_id: origin[:enqueued_id], client_id: origin[:client_id])
692
+ TurnCheckpoint.record_refused(@session_dir, origin.enqueued_id, reason: reason)
693
+ Participants.log(@session_id, :turn, state_dir: @state_dir, participant: origin.participant_id,
694
+ enqueued_id: origin.enqueued_id, status: "refused", reason: reason)
695
+ @engine.announce(type: :turn_dequeued, enqueued_id: origin.enqueued_id, client_id: origin.client_id)
694
696
  nil
695
697
  end
696
698
 
@@ -705,15 +707,18 @@ module Samagotchi
705
707
  @engine.clear_due_reminder_names!
706
708
  return false unless @engine.reminders_due?
707
709
 
708
- @continue_offer.drop({ client_id: ClientId::REMINDER })
710
+ @continue_offer.drop(REMINDER_ORIGIN)
709
711
  # A failure has no prompt to hand back (the Engine announced
710
712
  # :turn_failed). The offer went before the turn
711
713
  # (ContinueOffer#drop); either way the rollback window closes.
712
- run_engine_turn(nil, continue: true, origin: { client_id: ClientId::REMINDER },
714
+ run_engine_turn(nil, continue: true, origin: REMINDER_ORIGIN,
713
715
  max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
714
716
  true
715
717
  end
716
718
 
719
+ # A reminder turn's origin: chi's own (system:reminder).
720
+ REMINDER_ORIGIN = TurnOrigin.new(client_id: ClientId::REMINDER)
721
+
717
722
  # :due when an idle parent should run a turn for its delegate
718
723
  # children's reports now (session.delegate_reports: wake): rings wait,
719
724
  # no continue offer does (they merge into its turn), wakes aren't paused
@@ -1107,13 +1112,13 @@ module Samagotchi
1107
1112
  # Every kind of turn merges steering (a reminder turn too, which may be
1108
1113
  # a fresh worker's first), so the list is this turn's from the start.
1109
1114
  @merged_this_turn = []
1110
- @reporting_turn = reports_to_parent?(turn_args[:origin])
1115
+ origin = TurnOrigin.of(turn_args[:origin])
1116
+ @reporting_turn = reports_to_parent?(origin)
1111
1117
  # A participant's review turn merges no input (#pending_input_drain).
1112
- @participant_turn = Steer.participant_author?(turn_args[:origin]&.dig(:author))
1118
+ @participant_turn = origin.participant?
1113
1119
  restores = @turn_flow.restores
1114
1120
  begin
1115
- @checkpoint.begin_turn(prompt: prompt, enqueued_id: turn_args[:origin]&.dig(:enqueued_id),
1116
- author: turn_author_fields(turn_args[:origin]))
1121
+ @checkpoint.begin_turn(prompt: prompt, enqueued_id: origin.enqueued_id, author: turn_author_fields(origin))
1117
1122
  @session.status = Session::STATUS_RUNNING
1118
1123
  # A continue that moved this session to the chain's new link wrote
1119
1124
  # the new parent beside it (Session::PARENT_FILE): this turn's
@@ -1128,7 +1133,7 @@ module Samagotchi
1128
1133
  # (ReplyWait) tells a turn's end by last_turn.
1129
1134
  if @session.status == Session::STATUS_RUNNING
1130
1135
  @session.status = Session::STATUS_IDLE
1131
- @engine.record_last_turn(@session, "failed", 0, turn_args[:origin], { type: :turn_failed })
1136
+ @engine.record_last_turn(@session, "failed", 0, origin, { type: :turn_failed })
1132
1137
  Log.warn(:worker, "turn_not_begun", error: e.class.name, msg: e.message)
1133
1138
  end
1134
1139
  ensure
@@ -1142,7 +1147,7 @@ module Samagotchi
1142
1147
  # no reply: its text is what came before its last tool calls, and a
1143
1148
  # wait (chi send --wait) gets the question instead.
1144
1149
  unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
1145
- SessionInbox.write_output(@session_dir, response, enqueued_id: turn_args[:origin]&.dig(:enqueued_id))
1150
+ SessionInbox.write_output(@session_dir, response, enqueued_id: origin.enqueued_id)
1146
1151
  end
1147
1152
  save_or_log(:turn) { save_session }
1148
1153
  # The reports the turn merged were read unless its end took the turn
@@ -1200,7 +1205,7 @@ module Samagotchi
1200
1205
  def reports_to_parent?(origin)
1201
1206
  return false unless @session.delegate?
1202
1207
 
1203
- @initial_turn || @owes_parent || !ClientId.human?(origin&.dig(:client_id))
1208
+ @initial_turn || @owes_parent || !ClientId.human?(origin.client_id)
1204
1209
  end
1205
1210
 
1206
1211
  # The Engine published a question (after the question desk saved it).
@@ -1235,7 +1240,7 @@ module Samagotchi
1235
1240
  # or the restored ones, never the one without the other. A turn that
1236
1241
  # got somewhere stays instead (TurnFlow#prompt_turn_failed), with the
1237
1242
  # prompts it took: nothing goes back.
1238
- # @param prompts [Array<Array(String, Hash|nil, Array|nil)>] [prompt,
1243
+ # @param prompts [Array<Array(String, TurnOrigin|nil, Array|nil)>] [prompt,
1239
1244
  # origin, images] (a web client gets its image chips back)
1240
1245
  # @param error [Exception, nil] what failed: its one line stays in the
1241
1246
  # conversation as a turn note
@@ -1245,7 +1250,7 @@ module Samagotchi
1245
1250
  next if @turn_flow.prompt_turn_failed(error: error) == :kept
1246
1251
 
1247
1252
  prompts.each do |prompt, origin, images|
1248
- restored = { type: :prompt_restored, prompt: prompt, origin: origin }
1253
+ restored = { type: :prompt_restored, prompt: prompt, origin: origin.wire }
1249
1254
  restored[:images] = images unless Array(images).empty?
1250
1255
  @engine.announce(restored)
1251
1256
  end
@@ -1290,13 +1295,13 @@ module Samagotchi
1290
1295
  FileUtils.rm_f(claimed_file)
1291
1296
  end
1292
1297
  end
1293
- merged.each { |_prompt, origin| user_input(origin&.dig(:client_id)) }
1298
+ merged.each { |_prompt, origin| user_input(origin.client_id) }
1294
1299
  # Delegate children's reports: never handed back on a failure
1295
1300
  # (their rings stay for the next turn), so not in @merged_this_turn.
1296
1301
  reports, mark = take_child_reports
1297
1302
  unless merged.empty? && reports.empty?
1298
1303
  @engine.announce(type: :input_merged, count: merged.size + reports.size,
1299
- origins: merged.filter_map(&:last) + reports.map(&:origin))
1304
+ origins: (merged.map(&:last) + reports.map(&:origin)).filter_map(&:wire))
1300
1305
  @merged_this_turn.concat(merged)
1301
1306
  end
1302
1307
  # Each line keeps its sender (Steer.source_for_client): a chi send or
@@ -1315,12 +1320,12 @@ module Samagotchi
1315
1320
 
1316
1321
  # A participant's author fields (Engine#author_fields), {} for the owner.
1317
1322
  def turn_author_fields(origin)
1318
- Steer.participant_author?(origin&.dig(:author)) ? @engine.author_fields(origin[:author], session: @session) : {}
1323
+ origin.participant? ? @engine.author_fields(origin.author, session: @session) : {}
1319
1324
  end
1320
1325
 
1321
1326
  def merged_line(prompt, origin)
1322
1327
  author = turn_author_fields(origin)
1323
- Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id)),
1328
+ Steer::Line.new(text: prompt, source: Steer.source_for_client(origin.client_id),
1324
1329
  author: author[:author], author_name: author[:author_name])
1325
1330
  end
1326
1331
 
@@ -12,6 +12,7 @@ require_relative "log"
12
12
  require_relative "session"
13
13
  require_relative "session_inbox"
14
14
  require_relative "steer"
15
+ require_relative "turn_origin"
15
16
  require_relative "participants"
16
17
  require_relative "tools/task_record"
17
18
  require_relative "worker_wakes"
@@ -38,9 +39,11 @@ module Samagotchi
38
39
  # +no_interrupt+: an offer its turn makes keeps it for its continue
39
40
  # turn; +images+: its image refs ({file:, name:}); +delivery+: the wire
40
41
  # value the sender asked for ("queue", or nil for the default
41
- # next_step).
42
+ # next_step); +origin+: a TurnOrigin (a Hash is read as one).
42
43
  Prompt = Data.define(:text, :origin, :no_interrupt, :images, :delivery) do
43
- def initialize(text:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
44
+ def initialize(text:, origin: nil, no_interrupt: false, images: [], delivery: nil)
45
+ super(text:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:)
46
+ end
44
47
  end
45
48
 
46
49
  # A context wake turn to run: the ContextAbsorber::Delivery that woke
@@ -53,7 +56,7 @@ module Samagotchi
53
56
  # Whether the wake counts toward context.peer_turns: a peer's answer
54
57
  # that agrees closes the round and counts toward session.max_wakes only.
55
58
  def peer_turn? = peer? && !delivery.agreed
56
- def origin = { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" }
59
+ def origin = TurnOrigin.new(client_id: "#{ClientId::CONTEXT_PREFIX}#{name}")
57
60
  # The plain note #unmark_wake puts back.
58
61
  def note = delivery.note
59
62
  end
@@ -65,7 +68,7 @@ module Samagotchi
65
68
  # finished task it runs for (the others finished with it are plain notes).
66
69
  TaskWake = Data.define(:id, :turn_id, :plain_note) do
67
70
  def name = "task #{Tools::TaskRecord.short_id(id)}"
68
- def origin = { client_id: "#{ClientId::TASK_PREFIX}#{id}" }
71
+ def origin = TurnOrigin.new(client_id: "#{ClientId::TASK_PREFIX}#{id}")
69
72
  def note = plain_note
70
73
  end
71
74
 
@@ -192,7 +195,7 @@ module Samagotchi
192
195
  # running for a turn that won't run.
193
196
  # @return [Boolean] whether it was one
194
197
  def initial_command?(prompt)
195
- return false unless queue_as_command(prompt, nil)
198
+ return false unless queue_as_command(prompt, TurnOrigin::NONE)
196
199
 
197
200
  @session.status = Session::STATUS_IDLE
198
201
  save(:initial_command, unless_stopped: true)
@@ -246,14 +249,14 @@ module Samagotchi
246
249
  def queue_as_command(text, origin, after_file = nil)
247
250
  return false unless @engine.command_registry.command?(text.to_s)
248
251
 
249
- if (author = Steer.sender_author(origin&.dig(:author)))
250
- Log.warn(:worker, "participant_command_refused", participant: author)
251
- Participants.log(@session.id, :refused, state_dir: @state_dir, participant: author.delete_prefix("p:"),
252
+ if origin.participant?
253
+ Log.warn(:worker, "participant_command_refused", participant: origin.participant_author)
254
+ Participants.log(@session.id, :refused, state_dir: @state_dir, participant: origin.participant_id,
252
255
  what: "command", reason: "not_permitted")
253
256
  return true
254
257
  end
255
258
 
256
- @queue_command.call(text, origin&.dig(:client_id), after_file, origin&.dig(:enqueued_id))
259
+ @queue_command.call(text, origin.client_id, after_file, origin.enqueued_id)
257
260
  true
258
261
  end
259
262