samagotchi 0.106.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/bin/chi +2 -0
  4. data/docs/architecture.md +6 -1
  5. data/docs/cli.md +45 -5
  6. data/docs/guardrails.md +8 -2
  7. data/docs/internals/engine.md +17 -6
  8. data/docs/internals/participants.md +129 -30
  9. data/docs/internals/remote-sessions.md +12 -5
  10. data/docs/internals/working-set.md +2 -1
  11. data/docs/memory.md +20 -0
  12. data/docs/plugins.md +38 -3
  13. data/docs/releasing.md +13 -6
  14. data/docs/sessions.md +39 -4
  15. data/docs/sub-agent.md +3 -1
  16. data/docs/testing.md +7 -1
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -1
  18. data/lib/samagotchi/bridge.rb +7 -2
  19. data/lib/samagotchi/bridge_client/stream_request.rb +10 -3
  20. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -5
  21. data/lib/samagotchi/bundles/coordinator/plugin.rb +85 -5
  22. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +18 -0
  23. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  25. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  28. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  30. data/lib/samagotchi/child_reports.rb +2 -1
  31. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  32. data/lib/samagotchi/context_sources.rb +1 -6
  33. data/lib/samagotchi/continue_offer.rb +56 -7
  34. data/lib/samagotchi/engine.rb +36 -25
  35. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  36. data/lib/samagotchi/guardrails/review_boundary.rb +6 -57
  37. data/lib/samagotchi/guardrails/review_paths.rb +93 -0
  38. data/lib/samagotchi/idle_scheduler.rb +17 -2
  39. data/lib/samagotchi/last_turn.rb +7 -0
  40. data/lib/samagotchi/log_subscriber.rb +2 -1
  41. data/lib/samagotchi/memory_orphans.rb +74 -0
  42. data/lib/samagotchi/memory_paths.rb +4 -4
  43. data/lib/samagotchi/model_profile.rb +5 -3
  44. data/lib/samagotchi/parent_report.rb +34 -4
  45. data/lib/samagotchi/participant_messages.rb +11 -0
  46. data/lib/samagotchi/participants.rb +20 -6
  47. data/lib/samagotchi/plugin/api.rb +2 -1
  48. data/lib/samagotchi/plugin/sessions.rb +25 -0
  49. data/lib/samagotchi/remote_command.rb +1 -1
  50. data/lib/samagotchi/remotes.rb +23 -8
  51. data/lib/samagotchi/reply_wait.rb +7 -2
  52. data/lib/samagotchi/review_apply.rb +123 -0
  53. data/lib/samagotchi/review_command.rb +121 -0
  54. data/lib/samagotchi/review_start.rb +161 -0
  55. data/lib/samagotchi/run_options.rb +17 -2
  56. data/lib/samagotchi/send_command/busy.rb +5 -2
  57. data/lib/samagotchi/send_command/wait_target.rb +18 -8
  58. data/lib/samagotchi/send_command.rb +2 -0
  59. data/lib/samagotchi/session.rb +33 -5
  60. data/lib/samagotchi/session_access/contract.rb +10 -2
  61. data/lib/samagotchi/session_access/dispatch.rb +8 -2
  62. data/lib/samagotchi/session_access/http_transport.rb +10 -1
  63. data/lib/samagotchi/session_access/local.rb +19 -2
  64. data/lib/samagotchi/session_access/remote.rb +15 -1
  65. data/lib/samagotchi/session_access/web_bridge_client.rb +1 -1
  66. data/lib/samagotchi/session_access.rb +23 -10
  67. data/lib/samagotchi/session_chain.rb +10 -2
  68. data/lib/samagotchi/session_continue.rb +4 -3
  69. data/lib/samagotchi/session_inbox.rb +12 -8
  70. data/lib/samagotchi/session_manager.rb +30 -8
  71. data/lib/samagotchi/session_metrics.rb +7 -6
  72. data/lib/samagotchi/session_participants.rb +3 -1
  73. data/lib/samagotchi/session_review.rb +28 -0
  74. data/lib/samagotchi/share_command.rb +7 -1
  75. data/lib/samagotchi/steer.rb +11 -1
  76. data/lib/samagotchi/terminal_ui/attached_loop.rb +34 -27
  77. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  78. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  79. data/lib/samagotchi/testing/sessions.rb +10 -2
  80. data/lib/samagotchi/tools/memory.rb +16 -9
  81. data/lib/samagotchi/turn_checkpoint.rb +13 -0
  82. data/lib/samagotchi/turn_origin.rb +68 -0
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +108 -21
  85. data/lib/samagotchi/web/grants.rb +92 -17
  86. data/lib/samagotchi/web/participant_access.rb +56 -110
  87. data/lib/samagotchi/web/participant_review.rb +45 -2
  88. data/lib/samagotchi/web/participant_stream_filter.rb +118 -17
  89. data/lib/samagotchi/web/participant_view.rb +19 -14
  90. data/lib/samagotchi/web/participants_admin.rb +129 -0
  91. data/lib/samagotchi/web/public/app.js +60 -5
  92. data/lib/samagotchi/web/public/chip_popover.js +10 -3
  93. data/lib/samagotchi/web/public/data.js +10 -2
  94. data/lib/samagotchi/web/public/delivery_line.js +79 -15
  95. data/lib/samagotchi/web/public/index.html +26 -0
  96. data/lib/samagotchi/web/public/info_bar.js +7 -3
  97. data/lib/samagotchi/web/public/info_bar_model.js +7 -1
  98. data/lib/samagotchi/web/public/live_turn.js +3 -0
  99. data/lib/samagotchi/web/public/participant.html +58 -5
  100. data/lib/samagotchi/web/public/participant.js +172 -25
  101. data/lib/samagotchi/web/public/participant_model.js +89 -24
  102. data/lib/samagotchi/web/public/participant_names.js +37 -5
  103. data/lib/samagotchi/web/public/participants_panel.js +86 -18
  104. data/lib/samagotchi/web/public/question_cards.js +1 -0
  105. data/lib/samagotchi/web/public/review_apply.js +34 -0
  106. data/lib/samagotchi/web/public/review_start.js +101 -0
  107. data/lib/samagotchi/web/public/send_turn.js +25 -1
  108. data/lib/samagotchi/web/public/session_actions.js +44 -3
  109. data/lib/samagotchi/web/public/session_state.js +3 -0
  110. data/lib/samagotchi/web/public/start_page.js +38 -2
  111. data/lib/samagotchi/web/public/stream_handlers.js +10 -0
  112. data/lib/samagotchi/web/public/turn_html.js +2 -1
  113. data/lib/samagotchi/web/public/turn_view.js +5 -0
  114. data/lib/samagotchi/web/session_summary.rb +3 -0
  115. data/lib/samagotchi/worker.rb +44 -27
  116. data/lib/samagotchi/worker_inbound.rb +12 -9
  117. data/lib/samagotchi/working_set.rb +3 -2
  118. metadata +11 -1
@@ -78,7 +78,10 @@ module Samagotchi
78
78
  end
79
79
 
80
80
  # The session command against the remote: refused (exit 2), or run
81
- # with a Remote. A failure to reach it is the command's line, exit 1.
81
+ # with a Remote. A failure to reach it is the command's line, exit 1;
82
+ # a refusal the command doesn't word itself is its line too: exit 2
83
+ # when chi web refused a value (BadRequest, a usage error here), 1
84
+ # for any other.
82
85
  # @param subcommands [Hash] bin/chi's SUBCOMMANDS
83
86
  # @return [Integer] the exit status
84
87
  def run(subcommands, stderr: $stderr, transport: nil)
@@ -100,7 +103,10 @@ module Samagotchi
100
103
  rescue Remotes::Invalid => e
101
104
  stderr.puts("Error: #{e.message}")
102
105
  CLI::Exit::USAGE
103
- rescue RemoteFailed => e
106
+ rescue BadRequest => e
107
+ stderr.puts("chi #{@argv.first}: #{e.message}")
108
+ CLI::Exit::USAGE
109
+ rescue RemoteFailed, Refused => e
104
110
  stderr.puts("chi #{@argv.first}: #{e.message}")
105
111
  CLI::Exit::FAILED
106
112
  end
@@ -74,6 +74,13 @@ module Samagotchi
74
74
  failed("no answer within #{@open_timeout} s")
75
75
  rescue Net::ReadTimeout
76
76
  failed("it took the request but sent no answer within #{read_timeout} s")
77
+ rescue Net::WriteTimeout
78
+ failed("it took no request within #{read_timeout} s")
79
+ rescue Timeout::Error
80
+ failed("no answer in time")
81
+ rescue Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
82
+ # Something else listens there (an SSH banner, another server).
83
+ failed("it didn't answer HTTP (is it chi web's port?)")
77
84
  rescue SocketError => e
78
85
  failed(e.message.include?("getaddrinfo") ? "can't find host #{@uri.host}" : e.message)
79
86
  rescue Errno::EHOSTUNREACH, Errno::ENETUNREACH
@@ -87,7 +94,9 @@ module Samagotchi
87
94
  private
88
95
 
89
96
  def http(read_timeout)
90
- Net::HTTP.new(@uri.host, @uri.port).tap do |http|
97
+ # hostname: an IPv6 address bare ([::1] is the URL's); Net::HTTP
98
+ # brackets it in Host.
99
+ Net::HTTP.new(@uri.hostname, @uri.port).tap do |http|
91
100
  http.use_ssl = @uri.scheme == "https"
92
101
  http.open_timeout = @open_timeout
93
102
  http.read_timeout = read_timeout
@@ -12,6 +12,7 @@ require_relative "../context_sources"
12
12
  require_relative "../send_command/busy"
13
13
  require_relative "../chi_invocation"
14
14
  require_relative "../worker_sidecar"
15
+ require_relative "../review_start"
15
16
  require_relative "../launch_mode"
16
17
  require_relative "../participants"
17
18
  require_relative "../config"
@@ -114,8 +115,11 @@ module Samagotchi
114
115
  # @return [Session] the session created (its id, model_warning,
115
116
  # continues, moved_children)
116
117
  # @raise [SessionContinue::Refused, SessionManager::ArchiveRefused,
117
- # SessionManager::OwnedByTUI] refused; StandardError: could not start
118
+ # SessionManager::OwnedByTUI, ReviewStart::Refused] refused;
119
+ # StandardError: could not start
118
120
  def create(spec)
121
+ return create_review(spec) if spec.review
122
+
119
123
  start = spec.preview ? { prompt: spec.prompt, preview: spec.preview } : { prompt: spec.prompt }
120
124
  if spec.continues
121
125
  return SessionManager.continue_session(spec.continues, **start, model: spec.model_name, **dir)
@@ -125,6 +129,18 @@ module Samagotchi
125
129
  setup: spec.setup, **dir)
126
130
  end
127
131
 
132
+ # A review session (ReviewStart): a fork of review.from, or a fresh
133
+ # one in the spec's folder.
134
+ def create_review(spec)
135
+ review = spec.review
136
+ if review.from
137
+ return ReviewStart.fork(from: review.from, subjects: review.subjects, model: spec.model_name, state_dir: state_dir!)
138
+ end
139
+
140
+ ReviewStart.start(subjects: review.subjects, dir: spec.working_directory || Dir.pwd, model: spec.model_name,
141
+ setup: spec.setup || SessionSetup.new, state_dir: state_dir!)
142
+ end
143
+
128
144
  # Whether a just-created session's worker came up (its Bridge
129
145
  # answers) within +timeout+ seconds.
130
146
  def await_worker(id, timeout:)
@@ -353,7 +369,8 @@ module Samagotchi
353
369
  issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
354
370
  ip, port = local_web
355
371
  Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
356
- link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?)
372
+ link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?,
373
+ warning: ReviewStart.share_warning(Session.load(id, **dir)))
357
374
  rescue Web::Grants::Invalid => e
358
375
  raise BadRequest.new(e.message, code: "invalid_participant")
359
376
  end
@@ -259,7 +259,7 @@ module Samagotchi
259
259
 
260
260
  json = reply.json
261
261
  Invitation.new(participant: ParticipantView.from_h(json.fetch("participant")), token: json.fetch("token"),
262
- link: json.fetch("link"), lan: json["lan"] == true)
262
+ link: json.fetch("link"), lan: json["lan"] == true, warning: json["warning"])
263
263
  end
264
264
 
265
265
  # Revokes one participant (+participant+), a session's, or with no
@@ -458,6 +458,7 @@ module Samagotchi
458
458
  # @param fetched [ContextSources::Fetched]
459
459
  # @return [ContextPush]
460
460
  def context_push(target, name, fetched)
461
+ ContextSources.check_name!(name)
461
462
  reply = request(:post, "#{session_path(target.session_id)}/context/#{name}/push",
462
463
  body: { text: fetched.text, summary: fetched.summary, wake: fetched.wake, hint: fetched.hint })
463
464
  case reply.status
@@ -647,6 +648,8 @@ module Samagotchi
647
648
  end
648
649
 
649
650
  def create_body(spec)
651
+ return review_body(spec) if spec.review
652
+
650
653
  body = { model: spec.model_name, client_id: spec.client_id, history: false }
651
654
  if spec.preview || spec.idle
652
655
  body.merge!(idle: true, preview: spec.preview)
@@ -663,6 +666,17 @@ module Samagotchi
663
666
  body.compact
664
667
  end
665
668
 
669
+ # A review session's create (chi review): its subjects as typed,
670
+ # checked by the server against its own files; a fork names its
671
+ # source and takes its folder.
672
+ def review_body(spec)
673
+ review = { subjects: spec.review.subjects, from: spec.review.from }.compact
674
+ body = { review: review, model: spec.model_name }
675
+ body[:dir] = spec.working_directory unless spec.review.from
676
+ body.merge!(setup_body(spec.setup)) if spec.setup && !spec.review.from
677
+ body.compact
678
+ end
679
+
666
680
  def setup_body(words)
667
681
  llm = words.llm_context&.to_h { |field, word| [LLM_CONTEXT_KEYS.fetch(field), word] }
668
682
  { llm_context: llm, thinking: words.thinking, toolkit: words.toolkit,
@@ -203,7 +203,7 @@ module Samagotchi
203
203
  return refused("tls_unsupported") if uri.scheme == "https"
204
204
 
205
205
  token = @remote.entry.token
206
- opened = BridgeClient::StreamRequest.open(host: uri.host, port: uri.port,
206
+ opened = BridgeClient::StreamRequest.open(host: uri.hostname, port: uri.port,
207
207
  path: "/api/sessions/#{@session_id}/stream#{query}",
208
208
  last_event_id: last_event_id, timeout: timeout,
209
209
  headers: token ? { "Authorization" => "Bearer #{token}" } : {})
@@ -35,27 +35,27 @@ module Samagotchi
35
35
  class AuthRefused < RemoteFailed; end
36
36
 
37
37
  # The errors a participant's token meets (docs/internals/participants.md),
38
- # one message each, built from the remote's label ("remote studio
39
- # (http://…)"). chi web's code for each is its CODE.
38
+ # each the server's own words (ParticipantMessages) led by the remote's
39
+ # label ("remote studio (http://…)"). chi web's code for each is its CODE.
40
40
 
41
41
  # 401 token_revoked: the owner revoked the grant, or archived or
42
42
  # deleted its session.
43
43
  class TokenRevoked < AuthRefused
44
44
  CODE = "token_revoked"
45
- def initialize(label) = super("#{label}: this review link was revoked by the session's owner")
45
+ def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_REVOKED}")
46
46
  end
47
47
 
48
48
  # 401 token_expired: the grant's time ran out.
49
49
  class TokenExpired < AuthRefused
50
50
  CODE = "token_expired"
51
- def initialize(label) = super("#{label}: this review link expired: ask the session's owner for a new one")
51
+ def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_EXPIRED}")
52
52
  end
53
53
 
54
54
  # 403 not_permitted: a participant's token on a route only the session's
55
55
  # owner may use.
56
56
  class NotPermitted < RemoteFailed
57
57
  CODE = "not_permitted"
58
- def initialize(label) = super("#{label}: this token is a participant's: only the session's owner can do that")
58
+ def initialize(label) = super("#{label}: #{ParticipantMessages::NOT_PERMITTED}")
59
59
  end
60
60
 
61
61
  # 429 too_many_turns (P3b): the participant's turns already wait in the
@@ -102,8 +102,11 @@ module Samagotchi
102
102
  # What chi share made: the participant, their token and the link that
103
103
  # carries it (both shown this once: chi keeps only a digest), and
104
104
  # whether that chi web listens on the LAN (+lan+ false: the link works
105
- # on that machine only).
106
- Invitation = Data.define(:participant, :token, :link, :lan) do
105
+ # on that machine only), and the warning for sharing a working session
106
+ # (ReviewStart.share_warning; nil for a review session).
107
+ Invitation = Data.define(:participant, :token, :link, :lan, :warning) do
108
+ def initialize(participant:, token:, link:, lan:, warning: nil) = super
109
+
107
110
  def inspect = "#<Invitation #{participant.id}>"
108
111
  alias_method :to_s, :inspect
109
112
  end
@@ -120,11 +123,21 @@ module Samagotchi
120
123
  # bundles). +client_id+ (chi send's, or chi answer's for a parent's
121
124
  # /continue yes) and +images+ (images follow) are for a Remote's server,
122
125
  # which checks the first line and the model as chi send checks them
123
- # here (Local checks before it creates).
126
+ # here (Local checks before it creates). +review+ (a Review) makes it a
127
+ # review session (chi review): idle, no prompt, and with +review.from+
128
+ # a review fork of that session (its folder and setup: the rest is nil).
124
129
  NewSession = Data.define(:prompt, :preview, :continues, :working_directory, :model_name, :setup, :client_id,
125
- :images, :idle) do
130
+ :images, :idle, :review) do
126
131
  def initialize(prompt:, preview: nil, continues: nil, working_directory: nil, model_name: nil, setup: nil,
127
- client_id: nil, images: false, idle: false) = super
132
+ client_id: nil, images: false, idle: false, review: nil) = super
133
+ end
134
+
135
+ # A review session to create (NewSession#review): +subjects+ as typed
136
+ # (relative to the session's folder; the side that has the files checks
137
+ # them, ReviewStart.check_subjects), +from+ the session a review fork
138
+ # starts from (an id, a prefix or last:<id>), or nil.
139
+ Review = Data.define(:subjects, :from) do
140
+ def initialize(subjects: [], from: nil) = super(subjects: Array(subjects).map(&:to_s), from: from)
128
141
  end
129
142
 
130
143
  # chi send --new's setup flags as typed, for a Remote: +llm_context+
@@ -70,12 +70,20 @@ module Samagotchi
70
70
  # @return [String]
71
71
  def note_text(previous, recap:)
72
72
  head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
73
+ "#{head}#{recap_text(recap)}"
74
+ end
75
+
76
+ # +recap+ as a note's tail: " Its recap, as of <time>:\n<text>" (cut
77
+ # to RECAP_CHARS), or " It left no recap."
78
+ # @param recap [Hash, nil] RecapStore.read's
79
+ # @return [String]
80
+ def recap_text(recap)
73
81
  text = recap && recap[:text].to_s.strip
74
- return "#{head} It left no recap." if text.nil? || text.empty?
82
+ return " It left no recap." if text.nil? || text.empty?
75
83
 
76
84
  text = "#{text[0, RECAP_CHARS - 1]}…" if text.length > RECAP_CHARS
77
85
  as_of = clock(recap[:created_at])
78
- "#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
86
+ " Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
79
87
  end
80
88
 
81
89
  # What web `continue →` prefills in +previous+'s next link: +previous+'s
@@ -204,7 +204,7 @@ module Samagotchi
204
204
  end
205
205
  link = SessionManager.spawn_session(
206
206
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
207
- model_name: choice.model, setup: SessionSetup.of(previous),
207
+ model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
208
208
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
209
209
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
210
210
  )
@@ -300,8 +300,9 @@ module Samagotchi
300
300
  # IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
301
301
  # to change. A worker that is gone wrote its recap when it left; one
302
302
  # that has nothing new to say, too short a conversation, or recaps off
303
- # answers at once. Best effort: the continue goes on without.
304
- private_class_method def self.recap_before_archive(id, state_dir, wait:)
303
+ # answers at once. Best effort: the continue goes on without. A review
304
+ # fork (ReviewStart) asks the same, with nothing archived after.
305
+ def self.recap_before_archive(id, state_dir, wait:)
305
306
  return unless SessionManager.worker_live?(id, state_dir: state_dir)
306
307
 
307
308
  dir = Session.session_dir(id, state_dir: state_dir)
@@ -9,6 +9,7 @@ require_relative "delivery"
9
9
  require_relative "log"
10
10
  require_relative "session"
11
11
  require_relative "steer"
12
+ require_relative "turn_origin"
12
13
 
13
14
  module Samagotchi
14
15
  # A session directory's file inbox and outbox (see SessionManager for the
@@ -221,13 +222,16 @@ module Samagotchi
221
222
  Dir.glob(File.join(input_dir, "*.json"))
222
223
  end
223
224
 
224
- # A claimed input file as read: its +prompt+, +origin+ ({client_id:,
225
- # enqueued_id:}, nil when it names no sender), whether its turn runs
226
- # with the raised iteration limit (+no_interrupt+, --no-interrupt), its
227
- # +images+ ({file:, name:} refs), and its +delivery+ ("queue" or nil; a
228
- # file without the key is one an older chi wrote, so nil).
225
+ # A claimed input file as read: its +prompt+, +origin+ (a TurnOrigin
226
+ # from the file's client_id, enqueued_id and author; nil when it names
227
+ # no sender), whether its turn runs with the raised iteration limit
228
+ # (+no_interrupt+, --no-interrupt), its +images+ ({file:, name:} refs),
229
+ # and its +delivery+ ("queue" or nil; a file without the key is one an
230
+ # older chi wrote, so nil).
229
231
  Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
230
- def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
232
+ def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil)
233
+ super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:)
234
+ end
231
235
  end
232
236
  # A file that doesn't parse, or isn't a JSON object: no prompt.
233
237
  UNREADABLE_INPUT = Input.new(prompt: nil)
@@ -239,9 +243,9 @@ module Samagotchi
239
243
  return UNREADABLE_INPUT unless data.is_a?(Hash)
240
244
 
241
245
  # author: only a file that names one (an owner's names none).
242
- origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"], author: data["author"] }.compact
246
+ origin = TurnOrigin.from(data.slice("client_id", "enqueued_id", "author"))
243
247
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
244
- Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
248
+ Input.new(prompt: data["prompt"].to_s, origin: origin, no_interrupt: data["no_interrupt"] == true,
245
249
  images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
246
250
  rescue JSON::ParserError
247
251
  UNREADABLE_INPUT
@@ -157,6 +157,8 @@ module Samagotchi
157
157
  # @param note [String, nil] a context note the session starts with,
158
158
  # from +note_source+: queued before its worker spawns, so its first
159
159
  # turn sees it
160
+ # @param review [SessionReview, nil] a review session's (ReviewStart; a
161
+ # continue keeps its previous link's)
160
162
  # @param before_spawn [#call, nil] (session) runs once the session and
161
163
  # its note are saved, before its worker spawns
162
164
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
@@ -164,7 +166,7 @@ module Samagotchi
164
166
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
165
167
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
166
168
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
167
- note_source: "chi", before_spawn: nil)
169
+ note_source: "chi", before_spawn: nil, review: nil)
168
170
  sd = state_dir || Session.default_state_dir
169
171
  # The resolved ref is stored (a resumed session keeps its model when an
170
172
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -186,7 +188,8 @@ module Samagotchi
186
188
  messages: messages,
187
189
  delegate: delegate,
188
190
  setup: setup,
189
- continues: continues
191
+ continues: continues,
192
+ review: review
190
193
  )
191
194
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
192
195
  session.model_warning = model_warning
@@ -1284,18 +1287,23 @@ module Samagotchi
1284
1287
  ack: { "error" => "worker_timeout",
1285
1288
  "detail" => "the session's worker is exiting, so the message was not sent; send it again" })
1286
1289
  end
1287
- # No worker: the participant's waiting files are all their turns.
1288
- if author && pending_limit && pending_files(session_dir, author) >= pending_limit
1289
- return DeliverResult.new(status: :refused, code: 429,
1290
- ack: { "error" => "too_many_turns", "detail" => ParticipantMessages::TOO_MANY_TURNS })
1291
- end
1292
1290
  enqueued_id = SecureRandom.uuid
1293
1291
  input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
1294
1292
  input[:images] = images unless images.empty?
1295
1293
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1296
1294
  input[:no_interrupt] = true if no_interrupt
1297
1295
  input[:author] = author if author
1298
- path = manager.write_turn_input(session_id, **input)
1296
+ path = participant_queue_lock(session_dir, author && pending_limit) do
1297
+ # No worker: the participant's waiting files are all their turns,
1298
+ # counted and written under one lock (the Bridge counts under its).
1299
+ next :too_many if author && pending_limit && pending_files(session_dir, author) >= pending_limit
1300
+
1301
+ manager.write_turn_input(session_id, **input)
1302
+ end
1303
+ if path == :too_many
1304
+ return DeliverResult.new(status: :refused, code: 429,
1305
+ ack: { "error" => "too_many_turns", "detail" => ParticipantMessages::TOO_MANY_TURNS })
1306
+ end
1299
1307
  return DeliverResult.new(status: :failed) unless path
1300
1308
 
1301
1309
  owner = delivery_owner(manager, session_id, state_dir)
@@ -1326,6 +1334,20 @@ module Samagotchi
1326
1334
  end
1327
1335
 
1328
1336
  # The unclaimed input files +author+ wrote.
1337
+ PARTICIPANT_QUEUE_LOCK = "participants.lock"
1338
+
1339
+ # The block under the session's participant queue lock (a file lock:
1340
+ # chi web's threads, and another chi web), when +locked+; else as is.
1341
+ private_class_method def self.participant_queue_lock(session_dir, locked)
1342
+ return yield unless locked
1343
+
1344
+ FileUtils.mkdir_p(session_dir)
1345
+ File.open(File.join(session_dir, PARTICIPANT_QUEUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
1346
+ lock.flock(File::LOCK_EX)
1347
+ yield
1348
+ end
1349
+ end
1350
+
1329
1351
  private_class_method def self.pending_files(session_dir, author)
1330
1352
  SessionInbox.find_new_input_files(session_dir).count do |file|
1331
1353
  data = JSON.parse(File.read(file))
@@ -19,6 +19,7 @@ require_relative "session_metrics/usage"
19
19
  require_relative "session_metrics/side_totals"
20
20
  require_relative "side_usage"
21
21
  require_relative "steer"
22
+ require_relative "turn_origin"
22
23
 
23
24
  module Samagotchi
24
25
  # SessionMetrics is a persistent, error-isolated collector of per-session
@@ -727,6 +728,7 @@ module Samagotchi
727
728
  load_persisted
728
729
  end
729
730
  @started_at ||= now.iso8601(3)
731
+ origin = TurnOrigin.from(event[:origin])
730
732
  @turn = TurnState.new(
731
733
  session_id: @session_id,
732
734
  iteration_count: 0,
@@ -766,8 +768,8 @@ module Samagotchi
766
768
  gen_switch: false,
767
769
  continue: event[:continue] ? true : false,
768
770
  resumed: event[:resumed] ? true : false,
769
- author: Steer.sender_author(event[:origin].is_a?(Hash) ? event[:origin][:author] : nil),
770
- automatic: automatic_origin?(event[:origin]),
771
+ author: origin&.participant_author,
772
+ automatic: automatic_origin?(origin),
771
773
  id: event[:turn_id] || SecureRandom.uuid,
772
774
  started_at: now.iso8601(3),
773
775
  started_monotonic: monotonic_time,
@@ -818,11 +820,10 @@ module Samagotchi
818
820
  end
819
821
  end
820
822
 
821
- # Whether a turn's +origin+ names a client that is no person's: false
822
- # without one (an in-process turn is the owner's).
823
+ # Whether a turn's +origin+ (a TurnOrigin) names a client that is no
824
+ # person's: false without one (an in-process turn is the owner's).
823
825
  def automatic_origin?(origin)
824
- client_id = origin.is_a?(Hash) ? origin[:client_id] : nil
825
- !client_id.nil? && !ClientId.human?(client_id)
826
+ !origin&.client_id.nil? && !origin.human?
826
827
  end
827
828
 
828
829
  # The running turn's record so far, as end_turn would make it for a
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "steer"
4
+
3
5
  module Samagotchi
4
6
  # Loaded only when a session is read: it brings Web::Grants (and Rack).
5
7
  autoload :Participants, File.expand_path("participants", __dir__)
@@ -38,7 +40,7 @@ module Samagotchi
38
40
  return nil unless Steer.participant_author?(author)
39
41
 
40
42
  refresh!(session) if session && session.id != @session_id
41
- id = author.to_s.delete_prefix("p:")
43
+ id = Steer.participant_id(author)
42
44
  entry = @entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
43
45
  entry&.name
44
46
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A review session's own field (chi review, the web's Start review): the
5
+ # files under review, project-relative (the session's work tree top),
6
+ # and, for a review fork (chi review --from), the session it started from.
7
+ # A session without one isn't a review session; its file has no "review"
8
+ # key at all (Session::OPTIONAL_FIELDS), so other session files stay as
9
+ # they were. Kept by continues; never by forks or delegate children.
10
+ # See docs/sessions.md (Review sessions).
11
+ SessionReview = Data.define(:subjects, :from) do
12
+ def initialize(subjects: [], from: nil)
13
+ super(subjects: Array(subjects).map(&:to_s).reject(&:empty?).uniq.freeze, from: from&.to_s)
14
+ end
15
+
16
+ # A SessionReview from the session file's form ({"subjects" => […],
17
+ # "from" => id}), a SessionReview, or nil (nil for nil or a non-hash).
18
+ def self.of(value)
19
+ case value
20
+ when SessionReview then value
21
+ when Hash then new(subjects: value["subjects"] || value[:subjects], from: value["from"] || value[:from])
22
+ end
23
+ end
24
+
25
+ # The session file's form: "from" only when there is one.
26
+ def to_file = { "subjects" => subjects, "from" => from }.compact
27
+ end
28
+ end
@@ -93,6 +93,9 @@ module Samagotchi
93
93
  send(:"run_#{@sub || "invite"}", parsed.options, parsed.args)
94
94
  rescue SessionAccess::BadRequest => e
95
95
  usage_error(e.message)
96
+ rescue Web::Grants::Corrupt => e
97
+ error_line("#{command_name}: #{e.message}")
98
+ CLI::Exit::FAILED
96
99
  rescue ArgumentError => e
97
100
  error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : e.message.sub(/\ASession not found: /, "no session ")}")
98
101
  CLI::Exit::FAILED
@@ -189,8 +192,11 @@ module Samagotchi
189
192
  "#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}")
190
193
  @stdout.puts(invitation.link)
191
194
  @stdout.puts("(shown once: chi keeps only its digest; chi share revoke #{id[0, 8]} #{row.id} ends it)")
195
+ if invitation.warning
196
+ error_line("#{command_name}: warning: #{invitation.warning} (#{@access.invocation.command("review --from #{id[0, 8]}")} starts one)")
197
+ end
192
198
  unless invitation.lan
193
- @stdout.puts("chi web isn't on the LAN there (web.host: lan): this link opens on that machine only")
199
+ @stdout.puts("chi web isn't on the LAN there (web.host: lan): this link opens on that machine only, as the participant")
194
200
  end
195
201
  @stdout.puts(Web::QR.lines(invitation.link)) if @tty
196
202
  end
@@ -128,6 +128,9 @@ module Samagotchi
128
128
  PARTICIPANT_SOURCE = "participant"
129
129
  # The owner's author, when one is written out; absent means the same.
130
130
  OWNER_AUTHOR = "owner"
131
+ # A participant's author: this prefix, then its id (Web::Grants'
132
+ # grant id), "p:<id>" (#author_for, #participant_id).
133
+ PARTICIPANT_PREFIX = "p:"
131
134
  # A participant's message's keys, which every message copy keeps.
132
135
  AUTHOR_KEYS = %i[author author_name].freeze
133
136
 
@@ -187,7 +190,7 @@ module Samagotchi
187
190
  # again (ParticipantName), or the participant's id when it has none.
188
191
  def review_header(message)
189
192
  name = ParticipantName.clean(message[:author_name] || message["author_name"])
190
- name = ParticipantName.clean((message[:author] || message["author"]).to_s.delete_prefix("p:")) if name.empty?
193
+ name = ParticipantName.clean(participant_id(message[:author] || message["author"])) if name.empty?
191
194
  format(REVIEW_HEADER, name.empty? ? "a participant" : name)
192
195
  end
193
196
 
@@ -219,6 +222,13 @@ module Samagotchi
219
222
  # +author+ when it is a participant's, else nil (the owner).
220
223
  def sender_author(author) = participant_author?(author) ? author.to_s : nil
221
224
 
225
+ # The author a participant's turns and messages carry: "p:<id>".
226
+ def author_for(participant_id) = "#{PARTICIPANT_PREFIX}#{participant_id}"
227
+
228
+ # The participant id in +author+ ("p:<id>" → "<id>"); a string without
229
+ # the prefix as it is ("" for nil).
230
+ def participant_id(author) = author.to_s.delete_prefix(PARTICIPANT_PREFIX)
231
+
222
232
  # The text a steer or merged input goes out as: its header, a newline,
223
233
  # then +text+ (the message's content by default). Other messages: +text+.
224
234
  def wire_text(message, text = (message[:content] || message["content"]).to_s)