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
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_paths"
4
+ require_relative "shell_paths"
5
+ require_relative "../paths"
6
+ require_relative "../toolkit"
7
+ require_relative "../steer"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # A participant's turn may only read (D3, D4: it proposes, the owner
12
+ # applies). A core check, on in every guardrails mode and with
13
+ # guardrails.enabled: false, active only while the running turn's author
14
+ # is a participant (GuardrailWiring#checks); the owner's turns never get
15
+ # it. It denies, never asks (nobody on the participant's side may
16
+ # approve, and the owner's approval would be the participant's text
17
+ # acting with the owner's power), every call outside the review kit
18
+ # (Toolkit::REVIEW_TOOLS). Not forget_outputs: it would replace the
19
+ # owner's earlier tool outputs with a note of the participant's, which
20
+ # later owner turns read as their own output, with no review header.
21
+ # read: only under the session's project root, and never a file of
22
+ # the read denylist (chi's state and config dirs, home secret dirs,
23
+ # project secret files), checked on the path as given and as it
24
+ # resolves (a symlink in the project to ~/.ssh is ~/.ssh);
25
+ # memory_read: the project scope only, named (no fallback to system);
26
+ # anything else, call_tool's real target included: a call_tool of a
27
+ # built-in reaches the gate as that built-in (ToolRunner#unwrapped),
28
+ # so what is left of call_tool is a plugin or MCP tool, denied in v1.
29
+ # The declared tools stay the session's (no prompt rebuild per author),
30
+ # so a participant turn's model may still try a write: it is denied
31
+ # here, with the header's "This turn may only read" to keep it rare.
32
+ class ReviewBoundary
33
+ RULE = "review-boundary"
34
+ SOURCE = "core"
35
+ ALLOWED = Toolkit::REVIEW_TOOLS
36
+ PROJECT_SCOPE = "project"
37
+
38
+ # Home folders that hold keys and tokens, never read in a review turn.
39
+ HOME_SECRET_DIRS = %w[.ssh .gnupg .aws].freeze
40
+ # Project files that hold secrets, by a path part's name: .env*,
41
+ # netrc, keys and certificates, anything named credentials, package
42
+ # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
43
+ # secrets file; and a git config (.git/config, a submodule's
44
+ # .git/modules/x/config), checked apart.
45
+ SECRET_FILE = /\A(?:\.env.*|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
46
+ \.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
47
+ # The .docker/config.json kind: a dot dir's config holding tokens.
48
+ SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
49
+ GIT_DIR = ".git"
50
+
51
+ ADVICE = "This is a participant's review turn: answer from what you can read and propose any change as " \
52
+ "text or a diff for the owner to apply."
53
+ NOT_IN_KIT = "a participant's review turn may only read (read, memory_read): %s is not allowed"
54
+ OUTSIDE = "a participant's review turn reads only inside the session's project"
55
+ SECRET = "a participant's review turn can't read secrets or chi's own files"
56
+ HOME_ROOT = "a participant's review turn reads only inside a project, and this session's folder is the " \
57
+ "home folder or above it"
58
+ MEMORY_SCOPE = "a participant's review turn reads project memories only: call memory_read with scope: project"
59
+
60
+ # @param author [#call] → String, nil: the running turn's author
61
+ # (GuardrailWiring#turn_author); the check is off unless it is a
62
+ # participant's (Steer.participant_author?)
63
+ # @param root [#call] → String, nil: the session's project root (the
64
+ # work tree top of its folder); nil denies every read
65
+ # @param denied_dirs [#call] → Array<String>: chi's own dirs (state,
66
+ # config: sessions, grants, the web token, memories and .bundles,
67
+ # config.yml, hooks)
68
+ # @param home [#call] → String, nil
69
+ def initialize(author:, root:, denied_dirs: -> { ReviewBoundary.chi_dirs }, home: -> { Dir.home })
70
+ @author = author
71
+ @root = root
72
+ @denied_dirs = denied_dirs
73
+ @home = home
74
+ end
75
+
76
+ # chi's state dir and config dir, as Paths has them now.
77
+ def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
78
+
79
+ # Vote on +verdict+ (its call and targets).
80
+ def check(verdict)
81
+ return verdict unless Steer.participant_author?(@author.call)
82
+
83
+ reason = refusal(verdict)
84
+ return verdict unless reason
85
+
86
+ verdict.deny!(reason, rule: RULE, source: SOURCE, decided_by: "core", advice: ADVICE)
87
+ end
88
+
89
+ private
90
+
91
+ def refusal(verdict)
92
+ call = verdict.call || {}
93
+ tool = call[:name].to_s
94
+ return format(NOT_IN_KIT, tool_label(tool, verdict.targets)) unless ALLOWED.include?(tool)
95
+
96
+ case tool
97
+ when "read" then read_refusal(verdict.targets&.paths || [])
98
+ when "memory_read" then MEMORY_SCOPE unless call[:scope].to_s.strip.downcase == PROJECT_SCOPE
99
+ end
100
+ end
101
+
102
+ # A plugin or MCP tool behind call_tool names what it acts as.
103
+ def tool_label(tool, targets)
104
+ acts_as = targets&.acts_as
105
+ acts_as && acts_as != tool ? "#{tool} (#{acts_as})" : tool
106
+ end
107
+
108
+ def read_refusal(paths)
109
+ return OUTSIDE if paths.empty?
110
+
111
+ root = @root.call.to_s
112
+ return OUTSIDE if root.empty?
113
+
114
+ real_root = ProtectedPaths.real(root)
115
+ home = @home.call.to_s
116
+ return HOME_ROOT if !home.empty? && ShellPaths.within?(ProtectedPaths.real(home), real_root)
117
+
118
+ paths.each do |path|
119
+ given = File.expand_path(path)
120
+ real = ProtectedPaths.real(given)
121
+ return OUTSIDE unless ShellPaths.within?(real, real_root)
122
+ return SECRET if secret?(given, home) || secret?(real, home)
123
+ end
124
+ nil
125
+ end
126
+
127
+ def secret?(path, home)
128
+ dirs = @denied_dirs.call.compact.map { |dir| File.expand_path(dir) }
129
+ dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
130
+ dirs += HOME_SECRET_DIRS.map { |dir| File.join(home, dir) } unless home.empty?
131
+ return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
132
+
133
+ parts = path.split(File::SEPARATOR)
134
+ return true if parts.any? { |part| SECRET_FILE.match?(part) }
135
+ return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
136
+
137
+ git_config?(parts)
138
+ end
139
+
140
+ # A config file anywhere under a .git dir (.git/config,
141
+ # .git/modules/<sub>/config, a worktree's config.worktree).
142
+ def git_config?(parts)
143
+ git = parts.index { |part| part.casecmp?(GIT_DIR) } or return false
144
+ parts[(git + 1)..].any? { |part| part.downcase.start_with?("config") }
145
+ end
146
+ end
147
+ end
148
+ end
@@ -13,6 +13,7 @@ require_relative "guardrails/approvals"
13
13
  require_relative "guardrails/protected_paths"
14
14
  require_relative "guardrails/scratch_writes"
15
15
  require_relative "guardrails/child_boundary"
16
+ require_relative "guardrails/review_boundary"
16
17
  require_relative "guardrails/load_failures"
17
18
  require_relative "guardrails/rules"
18
19
  require_relative "guardrails/gate"
@@ -22,11 +22,13 @@ module Samagotchi
22
22
  # @param question_desk [#call] → QuestionDesk
23
23
  # @param cancel_controller [#call] → the running turn's CancellationController, nil
24
24
  # @param steer [#call] (text, source) → steers the running turn
25
- def initialize(event_routes:, question_desk:, cancel_controller:, steer:)
25
+ # @param turn_author [#call] → the running turn's participant author, nil
26
+ def initialize(event_routes:, question_desk:, cancel_controller:, steer:, turn_author: -> {})
26
27
  @event_routes = event_routes
27
28
  @question_desk = question_desk
28
29
  @cancel_controller = cancel_controller
29
30
  @steer = steer
31
+ @turn_author = turn_author
30
32
  @unknown_fallbacks = Set.new
31
33
  end
32
34
 
@@ -40,7 +42,8 @@ module Samagotchi
40
42
  ask_user: ->(question) { ask_user(question) },
41
43
  stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
42
44
  steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
43
- stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
45
+ stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) },
46
+ turn_author: @turn_author
44
47
  )
45
48
  end
46
49
 
@@ -119,9 +119,12 @@ module Samagotchi
119
119
  # cancels the running turn (true when it did); +steer+ takes (text:,
120
120
  # hook:) and puts the text into the running turn (Engine#steer, true
121
121
  # when queued); +stop_generation+ takes (reason:, hook:) and cuts the
122
- # streaming generation while the turn goes on (true when it did). A
123
- # registry without one gives hooks no-op helpers.
124
- Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, :stop_generation, keyword_init: true)
122
+ # streaming generation while the turn goes on (true when it did);
123
+ # +turn_author+ returns the running turn's author when a participant
124
+ # wrote it ("p:<id>"), nil for the owner's (every event of such a turn
125
+ # carries it as author:). A registry without one gives hooks no-op
126
+ # helpers.
127
+ Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, :stop_generation, :turn_author, keyword_init: true)
125
128
 
126
129
  # A thread-safe registry for named hook callbacks.
127
130
  #
@@ -386,6 +389,12 @@ module Samagotchi
386
389
  def with_runtime(event)
387
390
  return unless event.is_a?(Hash)
388
391
 
392
+ # A participant's turn: its events say whose it is (the owner's
393
+ # carry no author:, as before).
394
+ if !event.key?(:author) && (author = @runtime&.turn_author&.call)
395
+ event[:author] = author
396
+ end
397
+
389
398
  event[:notify] ||= lambda { |text, level: :info, fallback_for: nil|
390
399
  notice = { text: text.to_s, level: level, hook: event[:hook] }
391
400
  notice[:fallback_for] = fallback_for unless fallback_for.nil?
@@ -55,6 +55,8 @@ module Samagotchi
55
55
  TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
56
56
  TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
57
57
  DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
58
+ # A participant's message (chi share): a reviewer's, not the user's.
59
+ PARTICIPANT_LINE = "(a review contribution from %s, a participant; not the user's words)"
58
60
 
59
61
  module_function
60
62
 
@@ -75,6 +77,8 @@ module Samagotchi
75
77
  # A delegate child's report (ChildReports) is chi's news, not
76
78
  # what the user said.
77
79
  content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
80
+ # A participant's is theirs, by the name it was saved with.
81
+ content = "#{participant_line(message)}\n#{content}" if Steer.participant_message?(message)
78
82
  images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
79
83
  [content, *images].reject(&:empty?).join("\n")
80
84
  when "model", "assistant"
@@ -97,6 +101,11 @@ module Samagotchi
97
101
  end
98
102
  end
99
103
 
104
+ def participant_line(message)
105
+ name = ParticipantName.clean(message["author_name"])
106
+ format(PARTICIPANT_LINE, name.empty? ? "a participant" : name)
107
+ end
108
+
100
109
  def image_lines(images)
101
110
  Array(images).filter_map { |ref| "[image #{ref["name"] || File.basename(ref["file"].to_s)}]" if ref.is_a?(Hash) }
102
111
  end
@@ -16,9 +16,12 @@ module Samagotchi
16
16
  # known. enqueued_id: the input's id when the turn ran one (a POST /turn
17
17
  # ack's, an input file's), so a wait for a queued message (`chi send
18
18
  # --queue --wait`, ReplyWait's turn:) tells its own turn's end from
19
- # another's. A field may be nil (unset; left out of the file).
19
+ # another's. author: who wrote the turn when it wasn't the owner ("p:<id>",
20
+ # a participant; origin stays the category), nil for the owner. A field
21
+ # may be nil (unset; left out of the file).
20
22
  LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
21
- :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id) do
23
+ :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id,
24
+ :author) do
22
25
  def initialize(**fields)
23
26
  super(**members.to_h { |m| [m, nil] }, **fields)
24
27
  end
@@ -451,6 +451,7 @@ module Samagotchi
451
451
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
452
452
  message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
453
453
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
454
+ Steer::AUTHOR_KEYS.each { |key| message[key] = entry[key] if entry[key] }
454
455
  message[ToolResponse::LOADED] = entry[ToolResponse::LOADED] if entry[ToolResponse::LOADED].is_a?(Array)
455
456
  message[TOOL_CHANGE] = entry[TOOL_CHANGE] if entry[TOOL_CHANGE].is_a?(Hash)
456
457
  message
@@ -33,14 +33,20 @@ module Samagotchi
33
33
  end
34
34
  end
35
35
 
36
+ # A participant's turn waiting on the session's owner (a question or
37
+ # approval it raised, the owner's step-limit Continue offer it is held
38
+ # for): what chi web's participant wait says, and how it reads here.
39
+ WAITING_FOR_OWNER = "waiting for the session's owner"
40
+
36
41
  module_function
37
42
 
38
43
  # @param result [ReplyWait::Result]
39
44
  # @return [String] answered, question, failed, canceled, limit (the
40
45
  # turn ran out of iterations, nobody asked), not_continued (a Stop
41
46
  # answered the step-limit question), removed (the queued message was
42
- # taken back before it ran), no_answer, error, worker_gone, stopped
43
- # or running
47
+ # taken back before it ran), no_answer, error, worker_gone, stopped,
48
+ # waiting_for_owner (a participant's turn waits on the owner: chi
49
+ # web's participant wait, through --remote) or running
44
50
  def status(result)
45
51
  case result.status
46
52
  when :done then "answered"
@@ -57,6 +63,7 @@ module Samagotchi
57
63
  when :error then "error"
58
64
  when :worker_gone then "worker_gone"
59
65
  when :stopped then "stopped"
66
+ when :waiting_for_owner then "waiting_for_owner"
60
67
  # ReplyWait's own :canceled is the caller giving up (its cancelled:),
61
68
  # and :timeout the deadline: the turn goes on either way.
62
69
  else "running"
@@ -64,12 +71,13 @@ module Samagotchi
64
71
  end
65
72
 
66
73
  # @return [Integer] 0 answered (or not continued: the Stop asked for
67
- # was carried out), 3 a question waits, 4 still running, 1 anything
74
+ # was carried out), 3 a question waits (or, for a participant, the
75
+ # owner: waiting_for_owner), 4 still running, 1 anything
68
76
  # else
69
77
  def exit_status(result)
70
78
  case status(result)
71
79
  when "answered", "not_continued" then CLI::Exit::OK
72
- when "question" then CLI::Exit::QUESTION
80
+ when "question", "waiting_for_owner" then CLI::Exit::QUESTION
73
81
  when "running" then CLI::Exit::RUNNING
74
82
  else CLI::Exit::FAILED
75
83
  end
@@ -304,8 +312,12 @@ module Samagotchi
304
312
  when :error then "the worker failed: #{result.text}; #{shows} shows what happened"
305
313
  when :worker_gone then "the worker is gone; #{shows} shows what happened"
306
314
  when :stopped then "the session was stopped (chi sessions stop)"
315
+ when :waiting_for_owner
316
+ turn = turn_id || result.turn_id
317
+ turn.to_s.empty? ? WAITING_FOR_OWNER : "#{WAITING_FOR_OWNER}: #{via.command("send --wait --turn #{turn} #{session_id}")}"
307
318
  else
308
- wait_again = via.command("send --wait --turn #{turn_id} #{session_id}") unless turn_id.to_s.empty?
319
+ turn = turn_id || result.turn_id
320
+ wait_again = via.command("send --wait --turn #{turn} #{session_id}") unless turn.to_s.empty?
309
321
  hint = wait_again || shows
310
322
  timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
311
323
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The words a participant is refused with, one per condition, wherever
5
+ # it is met: chi web's routes, the Bridge, the worker, and the
6
+ # SessionAccess errors a chi --remote client raises for the same codes.
7
+ # No requires, so any file can use it.
8
+ module ParticipantMessages
9
+ # 403 not_permitted: a route, a command or a shell line only the owner may use.
10
+ NOT_PERMITTED = "a participant can't do that: only the session's owner can"
11
+ # 429 too_many_turns: their running turn and its queue are full.
12
+ TOO_MANY_TURNS = "this participant has too many turns waiting: wait for one to finish"
13
+ # 429 cap_reached: their grant spent its cap.
14
+ CAP_REACHED = "this participant's spending cap is reached: ask the session's owner to raise it"
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A participant's name as chi shows it to anyone, the model included: one
5
+ # line of at most MAX characters with no brackets (nor Unicode line
6
+ # breaks, nor full-width square brackets), so a name can't close or forge
7
+ # the review-contribution header (Steer.review_header). The grants store
8
+ # cleans a name at issue (Web::Grants.clean_name); the header cleans it
9
+ # again, whatever a session file or a sidecar holds.
10
+ module ParticipantName
11
+ MAX = 64
12
+ UNSAFE = /[[:cntrl:]\p{Zl}\p{Zp}\[\][]【】〔〕]/
13
+
14
+ module_function
15
+
16
+ # @return [String] "" when nothing is left
17
+ def clean(name)
18
+ name.to_s.gsub(UNSAFE, " ").squeeze(" ").strip[0, MAX].to_s.strip
19
+ end
20
+ end
21
+ end
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "json"
5
5
  require "time"
6
6
  require_relative "atomic_file"
7
+ require_relative "participant_messages"
7
8
  require_relative "archive_store"
8
9
  require_relative "session"
9
10
  require_relative "web/grants"
@@ -26,11 +27,16 @@ module Samagotchi
26
27
  module Participants
27
28
  FILE = "participants.json"
28
29
  LOG = "participants.jsonl"
30
+ # What a participant is told when they try what only the owner may do:
31
+ # a route (chi web's 403 not_permitted), a command or a shell line.
32
+ NOT_PERMITTED = ParticipantMessages::NOT_PERMITTED
29
33
 
30
34
  # One participant as the sidecar has them. +removed_at+ when the grant
31
35
  # was revoked (nil while it works, or once it simply expired:
32
- # +expires_at+ says when).
33
- Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at) do
36
+ # +expires_at+ says when). +cap+: their spending cap in dollars (the
37
+ # worker's check before their queued turn starts), nil in a sidecar
38
+ # from before caps.
39
+ Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at, :cap) do
34
40
  def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key.to_s]] })
35
41
 
36
42
  # Still in the review at +now+.
@@ -65,7 +71,8 @@ module Samagotchi
65
71
 
66
72
  entries = grants.map do |grant|
67
73
  { "id" => grant.id, "kind" => grant.kind, "name" => grant.name, "role" => grant.role,
68
- "added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at }
74
+ "added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at,
75
+ "cap" => grant.spend_cap }
69
76
  end
70
77
  AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
71
78
  end
@@ -85,20 +92,36 @@ module Samagotchi
85
92
  # @return [Web::Grants::Issued]
86
93
  # @raise [Web::Grants::Invalid] an archived session, or bad values
87
94
  # @raise [ArgumentError] no such session
88
- def invite(grants, session_id:, name:, kind: "human", ttl: nil, state_dir: Session.default_state_dir)
95
+ def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil, state_dir: Session.default_state_dir)
89
96
  raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
90
97
  if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
91
98
  raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
92
99
  end
93
100
 
94
- issued = grants.issue(session_id: session_id, name: name, kind: kind, **{ ttl: ttl }.compact)
101
+ issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap)
95
102
  write(session_id, grants.list(session_id: session_id), state_dir: state_dir)
96
103
  grant = issued.grant
97
104
  log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
98
- role: grant.role, expires_at: grant.expires_at)
105
+ role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap)
99
106
  issued
100
107
  end
101
108
 
109
+ # Sets participant +id+'s cap on +session_id+ (chi share cap): the
110
+ # grant, the sidecar (the worker's check) and the audit line.
111
+ # @return [Web::Grants::Grant, nil] nil when +id+ isn't an active grant there
112
+ # @raise [Web::Grants::Invalid] a cap out of range
113
+ def set_cap(grants, session_id:, id:, cap:, state_dir: Session.default_state_dir)
114
+ grant = grants.set_cap(id, cap, session_id: session_id) or return nil
115
+
116
+ write(session_id, grants.list(session_id: session_id), state_dir: state_dir)
117
+ log(session_id, :cap, state_dir: state_dir, participant: grant.id, cap: grant.spend_cap)
118
+ grant
119
+ end
120
+
121
+ # The refusal for a cap change of +id+, no active participant of
122
+ # +session_id+.
123
+ def no_participant(id, session_id) = "no active participant #{id} in #{session_id.to_s[0, 8]} (chi share ls lists them)"
124
+
102
125
  # Revokes grants (Web::Grants#revoke's arguments) and brings each
103
126
  # affected session's sidecar and audit trail along.
104
127
  # @return [Array<Web::Grants::Grant>] the grants it revoked
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Loaded only when a sidecar is read: it brings Web::Grants (and Rack).
5
+ autoload :Participants, File.expand_path("participants", __dir__)
6
+
7
+ # Who is in a session's review now, and who came or went since the last
8
+ # look: participants.json (Participants.read) read again when its mtime
9
+ # moves, each entry's expiry measured at every look. The worker asks
10
+ # #changes on its idle tick and announces :participants_changed; the
11
+ # Bridge's join snapshot asks #active (any thread). A session nobody was
12
+ # invited to has no sidecar: one stat per look. Joined means invited (an
13
+ # active grant the last look didn't have), left means revoked or expired:
14
+ # participants have no presence of their own yet. See
15
+ # docs/internals/participants.md (The owner's terminal).
16
+ class ParticipantsWatch
17
+ # Participants::FILE, without loading Participants for a stat.
18
+ FILE = "participants.json"
19
+
20
+ # What changed: the active participants now, the ones that joined, and
21
+ # the ones that left (each with +reason+ "revoked" or "expired").
22
+ Change = Data.define(:participants, :joined, :left) do
23
+ def to_event = { type: :participants_changed, participants: participants, joined: joined, left: left }
24
+ end
25
+
26
+ # @param session_id [#call] → the session's id (nil before it has one)
27
+ # @param state_dir [#call] → the sessions' state dir
28
+ # @param clock [#call] → Time
29
+ def initialize(session_id:, state_dir:, clock: -> { Time.now })
30
+ @session_id = session_id
31
+ @state_dir = state_dir
32
+ @clock = clock
33
+ @mutex = Mutex.new
34
+ @stamp = nil
35
+ @entries = []
36
+ @known = nil
37
+ end
38
+
39
+ # The participants in the review now: [{id:, name:, kind:}], [] for none.
40
+ # @return [Array<Hash>]
41
+ def active
42
+ @mutex.synchronize { active_rows(read_entries) }
43
+ end
44
+
45
+ # The change since the last call, nil when none. The first call only
46
+ # takes the state as it is (a worker's start announces nobody).
47
+ # @return [Change, nil]
48
+ def changes
49
+ @mutex.synchronize do
50
+ entries = read_entries
51
+ rows = active_rows(entries)
52
+ known = @known
53
+ @known = rows.to_h { |row| [row[:id], row] }
54
+ next nil if known.nil?
55
+
56
+ joined = rows.reject { |row| known.key?(row[:id]) }
57
+ left = known.values.reject { |row| @known.key?(row[:id]) }.map do |row|
58
+ entry = entries.find { |e| e.id == row[:id] }
59
+ row.merge(reason: entry&.removed_at ? "revoked" : "expired")
60
+ end
61
+ next nil if joined.empty? && left.empty?
62
+
63
+ Change.new(participants: rows, joined: joined, left: left)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def active_rows(entries)
70
+ now = @clock.call
71
+ entries.select { |entry| entry.active?(now) }.map { |entry| { id: entry.id, name: entry.name, kind: entry.kind } }
72
+ end
73
+
74
+ # The sidecar's entries, read again only when its mtime moved.
75
+ def read_entries
76
+ id = @session_id.call
77
+ return @entries = [] if id.to_s.empty?
78
+
79
+ file = File.join(Session.session_dir(id, state_dir: @state_dir.call), FILE)
80
+ stamp = [id, File.mtime(file)]
81
+ return @entries if stamp == @stamp
82
+
83
+ @stamp = stamp
84
+ @entries = Participants.read(id, state_dir: @state_dir.call)
85
+ rescue SystemCallError
86
+ @stamp = nil
87
+ @entries = []
88
+ end
89
+ end
90
+ end
@@ -61,7 +61,7 @@ module Samagotchi
61
61
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
62
62
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
63
63
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
64
- :cancel_controller, :cost, :usage, keyword_init: true)
64
+ :cancel_controller, :cost, :usage, :turn_author, keyword_init: true)
65
65
 
66
66
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
67
67
  # sent nothing usable. The message says why, for the user.
@@ -175,6 +175,12 @@ module Samagotchi
175
175
  # with no session
176
176
  def usage = @host.usage&.call || SessionMetrics::Usage.zero
177
177
 
178
+ # @return [String, nil] who wrote the running turn when it isn't the
179
+ # session's owner: a participant's "p:<id>" (chi share); nil for the
180
+ # owner's turns and between turns. A handler runs with the owner's
181
+ # power whatever the author (docs/hooks.md).
182
+ def turn_author = @host.turn_author&.call
183
+
178
184
  # @return [String, nil] the name of the session's toolkit ("full" for
179
185
  # one from before toolkits; nil with no session)
180
186
  def toolkit = @host.toolkit&.call
@@ -42,13 +42,21 @@ module Samagotchi
42
42
  line = IdleRecap::TranscriptFilter.build([message])
43
43
  next if line.strip.empty?
44
44
 
45
- "#{message["role"] == "user" ? "User" : "Assistant"}: #{line}"
45
+ "#{speaker(message)}: #{line}"
46
46
  end.join("\n\n")
47
47
  return text if text.length <= MAX_TRANSCRIPT_CHARS
48
48
 
49
49
  "[earlier conversation left out]\n\n#{text[-MAX_TRANSCRIPT_CHARS..]}"
50
50
  end
51
51
 
52
+ # Who a paragraph is: a participant's message is a Participant's (the
53
+ # filter's line names them), any other user message the User's.
54
+ def speaker(message)
55
+ return "Assistant" unless message["role"] == "user"
56
+
57
+ Steer.participant_message?(message) ? "Participant" : "User"
58
+ end
59
+
52
60
  # String keys, as TranscriptFilter reads a saved session.
53
61
  def stringified(messages)
54
62
  JSON.parse(JSON.generate(Array(messages)))
@@ -11,21 +11,25 @@ module Samagotchi
11
11
  # :queue, :refuse, :loop), +after_seq+ (the event count then, so a
12
12
  # turn's end knows it came before), +after_file+ (the input file it runs
13
13
  # after, a basename) and +enqueued_id+ (a file-delivered command's sender).
14
- QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file, :enqueued_id) do
14
+ # +author+: who sent it when it wasn't the owner ("p:<id>", as a turn's
15
+ # origin author), nil for the owner.
16
+ QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file, :enqueued_id,
17
+ :author) do
15
18
  def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil,
16
- enqueued_id: nil) = super
19
+ enqueued_id: nil, author: nil) = super
17
20
 
18
21
  # A new command with a fresh id.
19
- def self.mint(line, client_id: nil, card: false)
20
- new(command_id: SecureRandom.uuid, client_id: client_id, line: line, card: card ? true : false)
22
+ def self.mint(line, client_id: nil, card: false, author: nil)
23
+ new(command_id: SecureRandom.uuid, client_id: client_id, line: line, card: card ? true : false, author: author)
21
24
  end
22
25
 
23
26
  # The fields its command_queued and command_ran name: card only when
24
- # it is a card's.
27
+ # it is a card's, author only when it isn't the owner's.
25
28
  # @return [Hash]
26
29
  def event_fields
27
30
  fields = { command_id: command_id, client_id: client_id, line: line }
28
31
  fields[:card] = true if card
32
+ fields[:author] = author if author
29
33
  fields
30
34
  end
31
35