samagotchi 0.105.0 → 0.107.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +168 -1
- data/bin/chi +2 -0
- data/docs/architecture.md +10 -3
- data/docs/cli.md +90 -9
- data/docs/configuration.md +9 -5
- data/docs/guardrails.md +63 -1
- data/docs/hooks.md +9 -0
- data/docs/internals/engine.md +22 -4
- data/docs/internals/participants.md +319 -45
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/internals/remote-sessions.md +12 -5
- data/docs/internals/working-set.md +8 -2
- data/docs/plugins.md +31 -1
- data/docs/releasing.md +2 -0
- data/docs/sessions.md +36 -1
- data/docs/sub-agent.md +2 -0
- data/docs/testing.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +19 -3
- data/lib/samagotchi/bridge.rb +39 -1
- data/lib/samagotchi/bridge_client/stream_request.rb +10 -3
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +85 -5
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +18 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +21 -9
- data/lib/samagotchi/config.rb +16 -1
- data/lib/samagotchi/continue_offer.rb +60 -3
- data/lib/samagotchi/delivery.rb +10 -0
- data/lib/samagotchi/engine.rb +62 -6
- data/lib/samagotchi/guardrail_wiring.rb +13 -3
- data/lib/samagotchi/guardrails/review_boundary.rb +97 -0
- data/lib/samagotchi/guardrails/review_paths.rb +93 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hook_routes.rb +5 -2
- data/lib/samagotchi/hooks/registry.rb +12 -3
- data/lib/samagotchi/idle_recap.rb +9 -0
- data/lib/samagotchi/model_profile.rb +5 -3
- data/lib/samagotchi/parent_report.rb +39 -5
- data/lib/samagotchi/participant_messages.rb +27 -0
- data/lib/samagotchi/participants.rb +46 -9
- data/lib/samagotchi/participants_watch.rb +90 -0
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +7 -1
- data/lib/samagotchi/plugin/sessions.rb +25 -0
- data/lib/samagotchi/plugin/side_question.rb +9 -1
- data/lib/samagotchi/remote_command.rb +1 -1
- data/lib/samagotchi/remotes.rb +23 -8
- data/lib/samagotchi/reply_wait/poll.rb +18 -3
- data/lib/samagotchi/review_apply.rb +123 -0
- data/lib/samagotchi/review_command.rb +121 -0
- data/lib/samagotchi/review_start.rb +161 -0
- data/lib/samagotchi/run_options.rb +18 -3
- data/lib/samagotchi/send_command/wait_target.rb +5 -0
- data/lib/samagotchi/send_command.rb +43 -3
- data/lib/samagotchi/session.rb +33 -5
- data/lib/samagotchi/session_access/contract.rb +12 -2
- data/lib/samagotchi/session_access/dispatch.rb +8 -2
- data/lib/samagotchi/session_access/http_transport.rb +10 -1
- data/lib/samagotchi/session_access/local.rb +50 -11
- data/lib/samagotchi/session_access/remote.rb +34 -3
- data/lib/samagotchi/session_access/web_bridge_client.rb +1 -1
- data/lib/samagotchi/session_access.rb +29 -13
- data/lib/samagotchi/session_chain.rb +10 -2
- data/lib/samagotchi/session_commands.rb +6 -0
- data/lib/samagotchi/session_continue.rb +4 -3
- data/lib/samagotchi/session_inbox.rb +31 -5
- data/lib/samagotchi/session_manager.rb +68 -6
- data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
- data/lib/samagotchi/session_metrics.rb +88 -1
- data/lib/samagotchi/session_review.rb +28 -0
- data/lib/samagotchi/share_command.rb +68 -23
- data/lib/samagotchi/side_usage.rb +4 -2
- data/lib/samagotchi/steer.rb +37 -1
- data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +58 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
- data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
- data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/testing/host.rb +4 -2
- data/lib/samagotchi/testing/recorder.rb +3 -1
- data/lib/samagotchi/testing/sessions.rb +10 -2
- data/lib/samagotchi/toolkit.rb +21 -3
- data/lib/samagotchi/turn_checkpoint.rb +13 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +151 -41
- data/lib/samagotchi/web/grants.rb +172 -18
- data/lib/samagotchi/web/participant_access.rb +273 -46
- data/lib/samagotchi/web/participant_review.rb +208 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +258 -0
- data/lib/samagotchi/web/participant_view.rb +426 -0
- data/lib/samagotchi/web/public/app.js +75 -6
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/chip_popover.js +10 -3
- data/lib/samagotchi/web/public/data.js +30 -2
- data/lib/samagotchi/web/public/delivery_line.js +79 -15
- data/lib/samagotchi/web/public/index.html +97 -27
- data/lib/samagotchi/web/public/info_bar.js +17 -6
- data/lib/samagotchi/web/public/info_bar_model.js +15 -6
- data/lib/samagotchi/web/public/live_turn.js +3 -0
- data/lib/samagotchi/web/public/participant.html +172 -0
- data/lib/samagotchi/web/public/participant.js +447 -0
- data/lib/samagotchi/web/public/participant_model.js +220 -0
- data/lib/samagotchi/web/public/participant_names.js +53 -0
- data/lib/samagotchi/web/public/participants_panel.js +379 -0
- data/lib/samagotchi/web/public/question_cards.js +1 -0
- data/lib/samagotchi/web/public/review_apply.js +34 -0
- data/lib/samagotchi/web/public/review_start.js +101 -0
- data/lib/samagotchi/web/public/send_turn.js +25 -1
- data/lib/samagotchi/web/public/session_actions.js +44 -3
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/start_page.js +38 -2
- data/lib/samagotchi/web/public/stream_handlers.js +18 -1
- data/lib/samagotchi/web/public/tokens.css +12 -0
- data/lib/samagotchi/web/public/turn_events.js +16 -5
- data/lib/samagotchi/web/public/turn_html.js +9 -4
- data/lib/samagotchi/web/public/turn_view.js +7 -1
- data/lib/samagotchi/web/public/working_set_view.js +3 -3
- data/lib/samagotchi/web/qr.rb +14 -0
- data/lib/samagotchi/web/running.rb +52 -0
- data/lib/samagotchi/web/server.rb +13 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -0
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +121 -7
- data/lib/samagotchi/worker_inbound.rb +12 -4
- metadata +22 -1
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -34,12 +34,16 @@ module Samagotchi
|
|
|
34
34
|
PLUGIN = "plugin"
|
|
35
35
|
# A relayed answer (RelayVerifier): relay:<parent8>.
|
|
36
36
|
RELAY_PREFIX = "relay:"
|
|
37
|
+
# A participant's review turn: review:<participant id> (chi web stamps
|
|
38
|
+
# it from the participant's token, never from the request's body).
|
|
39
|
+
REVIEW_PREFIX = "review:"
|
|
37
40
|
|
|
38
|
-
# Input a human typed: a web tab, a chi TUI, chi send,
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
41
|
+
# Input a human typed: a web tab, a chi TUI, chi send, a participant
|
|
42
|
+
# (review:, a person or an agent they run: never automatic input), and
|
|
43
|
+
# nil (a worker's initial prompt; a web client may send none). An
|
|
44
|
+
# allowlist: delegates, plugins, reminders, context changes and any id
|
|
45
|
+
# chi doesn't know are not.
|
|
46
|
+
HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX, REVIEW_PREFIX].freeze
|
|
43
47
|
HUMAN_IDS = [CLI_SEND].freeze
|
|
44
48
|
|
|
45
49
|
# A person at a screen that shows a command's reply and can answer it
|
|
@@ -47,16 +51,17 @@ module Samagotchi
|
|
|
47
51
|
INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
48
52
|
|
|
49
53
|
# A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
|
|
50
|
-
# runs turns for on its own, by prefix
|
|
51
|
-
# anything else "client".
|
|
54
|
+
# runs turns for on its own, by prefix, and a participant's ("review");
|
|
55
|
+
# system:reminder is "reminder", anything else "client".
|
|
52
56
|
ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
|
|
53
|
-
CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
|
|
57
|
+
CONTEXT_PREFIX => "context", TASK_PREFIX => "task", REVIEW_PREFIX => "review" }.freeze
|
|
54
58
|
|
|
55
59
|
module_function
|
|
56
60
|
|
|
57
61
|
# What sent a turn: "delegate" (a delegating parent), "delegate_report"
|
|
58
62
|
# (a child's report), "context" (a context wake), "task" (a task finish
|
|
59
|
-
# wake), "reminder", else "client" (
|
|
63
|
+
# wake), "review" (a participant), "reminder", else "client" (the
|
|
64
|
+
# owner, chi send, a plugin, nil).
|
|
60
65
|
# @return [String]
|
|
61
66
|
def origin(client_id)
|
|
62
67
|
id = client_id.to_s
|
|
@@ -81,5 +86,12 @@ module Samagotchi
|
|
|
81
86
|
id = client_id.to_s
|
|
82
87
|
HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
83
88
|
end
|
|
89
|
+
|
|
90
|
+
# The client id a participant's turn carries (chi web's, from their
|
|
91
|
+
# grant): review:<participant id>.
|
|
92
|
+
def review(participant_id) = "#{REVIEW_PREFIX}#{participant_id}"
|
|
93
|
+
|
|
94
|
+
# Whether +client_id+ is a participant's (review:).
|
|
95
|
+
def review?(client_id) = client_id.to_s.start_with?(REVIEW_PREFIX)
|
|
84
96
|
end
|
|
85
97
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -311,6 +311,11 @@ module Samagotchi
|
|
|
311
311
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
|
|
312
312
|
# What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
|
|
313
313
|
Entry.new(key: "web.editor", yaml_path: %w[web editor], type: :string, default: "vscode", expose: %i[env config cli], lifecycle: :web_start),
|
|
314
|
+
# Participants (chi share): a new grant's lifetime (Nh or Nd, 1h to 30d), and what a grant without its own
|
|
315
|
+
# takes: its spending cap in dollars ($0 to $1000) and how many of its turns may wait (0 to 20). Read when used.
|
|
316
|
+
Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
317
|
+
Entry.new(key: "web.participants.cap", yaml_path: %w[web participants cap], type: :float, default: 2.0, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
318
|
+
Entry.new(key: "web.participants.queue", yaml_path: %w[web participants queue], type: :integer, default: 3, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
314
319
|
|
|
315
320
|
Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
|
|
316
321
|
Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
|
|
@@ -609,7 +614,7 @@ module Samagotchi
|
|
|
609
614
|
elsif MAP_ENTRY_KEYS.key?(key)
|
|
610
615
|
map_entry_problems(key, value)
|
|
611
616
|
elsif value.is_a?(Hash) && (known_sections.include?(key) || ENTRIES.any? { |e| e.yaml_path.size > 1 && e.section == key })
|
|
612
|
-
value.
|
|
617
|
+
value.flat_map { |leaf, inner| leaf_problems("#{key}.#{leaf}", inner) }
|
|
613
618
|
elsif known_sections.include?(key)
|
|
614
619
|
# An all-commented section is nil; recap: false turns recaps off.
|
|
615
620
|
value.nil? || (key == "recap" && value == false) ? [] : ["config: '#{key}' must be a mapping of settings; ignored"]
|
|
@@ -619,6 +624,16 @@ module Samagotchi
|
|
|
619
624
|
end + model_ref_problems(data)
|
|
620
625
|
end
|
|
621
626
|
|
|
627
|
+
# A section's leaf: a key the registry knows, or a mapping of a
|
|
628
|
+
# third level (web.participants.cap) whose keys it knows.
|
|
629
|
+
def leaf_problems(path, value)
|
|
630
|
+
if value.is_a?(Hash) && ENTRIES.any? { |e| e.key.start_with?("#{path}.") }
|
|
631
|
+
value.keys.filter_map { |leaf| key_problem("#{path}.#{leaf}") }
|
|
632
|
+
else
|
|
633
|
+
[key_problem(path)].compact
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
622
637
|
# What a model ref in config.yml won't do as written: an alias whose
|
|
623
638
|
# target is another alias (aliases apply once, ModelRef), a ref that
|
|
624
639
|
# starts with "<host>/" (only ':' names a host).
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "last_turn"
|
|
5
5
|
require_relative "log"
|
|
6
|
+
require_relative "steer"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# A session worker's continue offer: what it announces when a turn runs out
|
|
@@ -34,12 +35,18 @@ module Samagotchi
|
|
|
34
35
|
# @param max_iterations [#call] (no_interrupt) → Integer
|
|
35
36
|
# @param queue_command [#call] (line, client_id:) queues a session
|
|
36
37
|
# command as a card's (Bridge#queue_command, card: true)
|
|
37
|
-
|
|
38
|
+
# +participant_refused+ (author → reason or nil): why a participant's
|
|
39
|
+
# turn may no longer run ("revoked", "cap_reached": Worker's check).
|
|
40
|
+
# @param save [#call] saves the session (a refused Continue's Stop)
|
|
41
|
+
def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil, participant_refused: ->(_) {},
|
|
42
|
+
save: -> {})
|
|
38
43
|
@engine = engine
|
|
44
|
+
@save = save
|
|
39
45
|
@turn_flow = turn_flow
|
|
40
46
|
@run_turn = run_turn
|
|
41
47
|
@max_iterations = max_iterations
|
|
42
48
|
@queue_command = queue_command
|
|
49
|
+
@participant_refused = participant_refused
|
|
43
50
|
# A question was posted since the last close (it may have been
|
|
44
51
|
# answered or superseded meanwhile).
|
|
45
52
|
@asked = false
|
|
@@ -87,7 +94,14 @@ module Samagotchi
|
|
|
87
94
|
# it ends (chi answer --option Stop).
|
|
88
95
|
# It carries the offer turn's enqueued_id, so a wait on that turn
|
|
89
96
|
# (ReplyWait's turn:) ends on it too.
|
|
97
|
+
# A Continue on the offer of a participant who may no longer run a turn
|
|
98
|
+
# (revoked, expired, at the cap since) is decided here, before anything
|
|
99
|
+
# says "resume": it ends the offer as a Stop does (#refuse_continue).
|
|
90
100
|
def announce_resolved(result, command)
|
|
101
|
+
if result.resume && (reason = refusal_of_offer_author)
|
|
102
|
+
return refuse_continue(reason, command)
|
|
103
|
+
end
|
|
104
|
+
|
|
91
105
|
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
|
|
92
106
|
return if result.resume
|
|
93
107
|
|
|
@@ -124,12 +138,24 @@ module Samagotchi
|
|
|
124
138
|
# never began leaves it to no other.
|
|
125
139
|
# The continue turn is the offer turn's: its origin carries that turn's
|
|
126
140
|
# enqueued_id, so its last_turn, its reply file and its checkpoint mark
|
|
127
|
-
# do too, and a wait on that turn (chi answer) gets its reply
|
|
141
|
+
# do too, and a wait on that turn (chi answer) gets its reply; and its
|
|
142
|
+
# author, so a participant's turn continues as theirs, inside the
|
|
143
|
+
# review boundary and on their spend (never as the owner's). One whose
|
|
144
|
+
# participant may no longer run a turn isn't continued.
|
|
128
145
|
def run_continue_turn(command)
|
|
146
|
+
# Refused as it was answered (#announce_resolved): the offer is gone.
|
|
147
|
+
return @continue_steer = nil unless awaiting?
|
|
148
|
+
|
|
129
149
|
offer = @turn_flow.offer
|
|
130
150
|
resume_from = @turn_flow.resume_from
|
|
131
151
|
enqueued_id = offer_turn_id
|
|
152
|
+
author = Steer.sender_author(@engine.session&.last_turn&.author)
|
|
132
153
|
close("answered")
|
|
154
|
+
if author && (reason = @participant_refused.call(author))
|
|
155
|
+
@continue_steer = nil
|
|
156
|
+
return @engine.synchronize_events { refuse_continue(reason, command) }
|
|
157
|
+
end
|
|
158
|
+
|
|
133
159
|
steer = @continue_steer
|
|
134
160
|
@continue_steer = nil
|
|
135
161
|
begin
|
|
@@ -140,7 +166,7 @@ module Samagotchi
|
|
|
140
166
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
141
167
|
return announce_offer(offer)
|
|
142
168
|
end
|
|
143
|
-
origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
|
|
169
|
+
origin = { client_id: command.client_id, enqueued_id: enqueued_id, author: author }.compact
|
|
144
170
|
@run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
|
|
145
171
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
146
172
|
if error
|
|
@@ -165,6 +191,37 @@ module Samagotchi
|
|
|
165
191
|
|
|
166
192
|
private
|
|
167
193
|
|
|
194
|
+
def refusal_of_offer_author
|
|
195
|
+
author = Steer.sender_author(@engine.session&.last_turn&.author)
|
|
196
|
+
author && @participant_refused.call(author)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The offer of a participant refused since (+reason+: revoked,
|
|
200
|
+
# cap_reached) ends as a Stop: announced as "refused" (with a notice
|
|
201
|
+
# for the owner), the model reads it wasn't continued, last_turn says
|
|
202
|
+
# not_continued with the offer turn's enqueued_id (a wait on it ends),
|
|
203
|
+
# and awaiting_continue? goes false (idle exit, /exit, the held
|
|
204
|
+
# participant turns go on). The question goes with the next close.
|
|
205
|
+
# Called with the event log held.
|
|
206
|
+
def refuse_continue(reason, command)
|
|
207
|
+
author = @engine.session&.last_turn&.author
|
|
208
|
+
enqueued_id = offer_turn_id
|
|
209
|
+
@engine.announce(type: :continue_resolved, decision: "refused", reason: reason.to_s, client_id: command.client_id)
|
|
210
|
+
@engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
|
|
211
|
+
text: format(REFUSED_NOTICE, who: author.to_s.delete_prefix("p:"), reason: REFUSED_WHY.fetch(reason.to_s, reason.to_s)))
|
|
212
|
+
@turn_flow.abort_continue!
|
|
213
|
+
session = @engine.session
|
|
214
|
+
session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3), enqueued_id: enqueued_id) if session
|
|
215
|
+
@continue_steer = nil
|
|
216
|
+
close("answered")
|
|
217
|
+
@save.call
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
REFUSED_NOTICE = "Not continued: participant %<who>s's turn %<reason>s."
|
|
222
|
+
REFUSED_WHY = { "revoked" => "is refused (their link was revoked or expired)",
|
|
223
|
+
"cap_reached" => "is refused (they reached their review cap)" }.freeze
|
|
224
|
+
|
|
168
225
|
# The enqueued_id of the turn the offer stands for: the exhausted
|
|
169
226
|
# turn's last_turn (a canceled continue turn's carries the same id);
|
|
170
227
|
# nil when it ran no input (a reminder turn, a delegate's first).
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -48,6 +48,16 @@ module Samagotchi
|
|
|
48
48
|
"restart it (chi sessions restart ID, or the web's update badge) and send again"
|
|
49
49
|
}.freeze
|
|
50
50
|
|
|
51
|
+
# A worker that runs participants' turns inside the review boundary
|
|
52
|
+
# (Bridge::FEATURES): one without it would run theirs with the owner's
|
|
53
|
+
# tools, so none is sent to it.
|
|
54
|
+
REVIEW_FEATURE = "review_turns"
|
|
55
|
+
REVIEW_UNAVAILABLE = {
|
|
56
|
+
"error" => "review_unavailable",
|
|
57
|
+
"detail" => "this session's worker predates participants' turns, so it has no review boundary; " \
|
|
58
|
+
"the owner must restart it (chi sessions restart ID, or the web's update badge)"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
51
61
|
# Whether it is the one every sender defaulting to a step boundary uses.
|
|
52
62
|
def self.next_step?(value) = parse(value) == NEXT_STEP
|
|
53
63
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -85,6 +85,7 @@ require_relative "guardrail_wiring"
|
|
|
85
85
|
require_relative "plugin_tasks"
|
|
86
86
|
require_relative "prompt_warmup"
|
|
87
87
|
require_relative "working_set"
|
|
88
|
+
require_relative "participants_watch"
|
|
88
89
|
require_relative "session_participants"
|
|
89
90
|
|
|
90
91
|
module Samagotchi
|
|
@@ -224,6 +225,8 @@ module Samagotchi
|
|
|
224
225
|
@participants = SessionParticipants.new(state_dir: -> { session_state_dir })
|
|
225
226
|
# Whether the system prompt carries the participants line.
|
|
226
227
|
@participants_line = false
|
|
228
|
+
# Who is in the review now, and who came or went (#announce_participants).
|
|
229
|
+
@participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
227
230
|
@session = nil
|
|
228
231
|
@session_observer = SessionObserver.new
|
|
229
232
|
@metrics = SessionMetrics.new
|
|
@@ -595,9 +598,12 @@ module Samagotchi
|
|
|
595
598
|
# recreate a deleted session's folder; the folder itself may not exist
|
|
596
599
|
# yet before a first turn). Safe from any thread: the recap's, a
|
|
597
600
|
# plugin's, the turn's.
|
|
601
|
+
# A side request counts against the participant who caused it
|
|
602
|
+
# (#side_author), its usage's own author when it names one.
|
|
598
603
|
# @param usage [SideUsage]
|
|
599
604
|
# @return [void]
|
|
600
605
|
def note_side_usage(usage)
|
|
606
|
+
usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
|
|
601
607
|
synchronize_events do
|
|
602
608
|
block = @metrics.add_side(usage)
|
|
603
609
|
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
@@ -609,6 +615,24 @@ module Samagotchi
|
|
|
609
615
|
@metrics.persist(state_dir: session_state_dir)
|
|
610
616
|
end
|
|
611
617
|
|
|
618
|
+
# Who caused a side request of +kind+ now: the running turn's author (a
|
|
619
|
+
# plugin's ctx.ask_model in it), the last turn's for the idle recap
|
|
620
|
+
# (it recaps that turn); nil, the owner's, for anything else (an owner
|
|
621
|
+
# command's ask_model between turns).
|
|
622
|
+
# @return [String, nil]
|
|
623
|
+
def side_author(kind)
|
|
624
|
+
return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
|
|
625
|
+
|
|
626
|
+
Steer.sender_author(@guardrail_wiring.turn_author)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
RECAP_SIDE_KIND = "recap"
|
|
630
|
+
|
|
631
|
+
# The running turn's author when a participant wrote it, else nil
|
|
632
|
+
# (ctx.turn_author, the hooks' author).
|
|
633
|
+
# @return [String, nil]
|
|
634
|
+
def turn_author = turn_running? ? Steer.sender_author(@guardrail_wiring.turn_author) : nil
|
|
635
|
+
|
|
612
636
|
# @return [SessionMetrics] the per-session analytics collector
|
|
613
637
|
attr_reader :metrics
|
|
614
638
|
|
|
@@ -797,7 +821,7 @@ module Samagotchi
|
|
|
797
821
|
# UIs need in the event log, emitted outside any turn's stream.
|
|
798
822
|
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
799
823
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
800
|
-
plugin_init_started plugin_init_finished working_set_updated].freeze
|
|
824
|
+
plugin_init_started plugin_init_finished working_set_updated participants_changed].freeze
|
|
801
825
|
|
|
802
826
|
# Put a transport-level event into the ordered event log. Unlike turn
|
|
803
827
|
# events it reaches only persistent observers (no turn sink, no memory
|
|
@@ -1186,6 +1210,11 @@ module Samagotchi
|
|
|
1186
1210
|
price_host: model.host_name)
|
|
1187
1211
|
kit_prompt = kit_prompt_stats
|
|
1188
1212
|
snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
|
|
1213
|
+
# Each participant's spend under their name (participants.json's).
|
|
1214
|
+
if (spend = snapshot[:spend_by_author])
|
|
1215
|
+
named = spend.to_h { |author, row| [author, row.merge(name: @participants.name_for(author, session: @session))] }
|
|
1216
|
+
snapshot = snapshot.merge(spend_by_author: named)
|
|
1217
|
+
end
|
|
1189
1218
|
# The session's own settings (chi --set), as --set names them.
|
|
1190
1219
|
settings = @session&.settings&.flat || {}
|
|
1191
1220
|
snapshot = snapshot.merge(settings: settings) unless settings.empty?
|
|
@@ -1302,6 +1331,8 @@ module Samagotchi
|
|
|
1302
1331
|
# that served model under served: (#served_expected_by)
|
|
1303
1332
|
# :recap_enabled [Boolean] whether an idle recap is configured, with
|
|
1304
1333
|
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
1334
|
+
# :participants [Array<Hash>] who is in the review now ({id:, name:,
|
|
1335
|
+
# kind:}; ParticipantsWatch#active), [] for nobody
|
|
1305
1336
|
def session_state_snapshot
|
|
1306
1337
|
served_pair = served_model(probe: false)
|
|
1307
1338
|
metrics = @metrics.snapshot
|
|
@@ -1330,7 +1361,8 @@ module Samagotchi
|
|
|
1330
1361
|
**setting_chips(SessionSetting::ALL, model),
|
|
1331
1362
|
recap_enabled: !@recap.nil?,
|
|
1332
1363
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
1333
|
-
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
1364
|
+
recap_inactivity_seconds: @recap&.inactivity&.to_i,
|
|
1365
|
+
participants: @participants_watch.active
|
|
1334
1366
|
}
|
|
1335
1367
|
end
|
|
1336
1368
|
|
|
@@ -1473,6 +1505,17 @@ module Samagotchi
|
|
|
1473
1505
|
**stop_facts(event))
|
|
1474
1506
|
end
|
|
1475
1507
|
|
|
1508
|
+
# A participant joined (invited) or left (revoked, expired) since the
|
|
1509
|
+
# last look: :participants_changed to the observers (ParticipantsWatch;
|
|
1510
|
+
# the worker looks on its idle tick). The first look announces nobody.
|
|
1511
|
+
# @return [ParticipantsWatch::Change, nil]
|
|
1512
|
+
def announce_participants
|
|
1513
|
+
change = @participants_watch.changes or return nil
|
|
1514
|
+
|
|
1515
|
+
announce(change.to_event)
|
|
1516
|
+
change
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1476
1519
|
# Reads the session's participants at a turn's start; the first to join
|
|
1477
1520
|
# and the last to leave rebuild the system prompt (its participants
|
|
1478
1521
|
# line), once each: a turn in between sends the same prefix.
|
|
@@ -1640,7 +1683,8 @@ module Samagotchi
|
|
|
1640
1683
|
event_routes: @event_routes,
|
|
1641
1684
|
question_desk: -> { @question_desk },
|
|
1642
1685
|
cancel_controller: -> { active_cancel_controller },
|
|
1643
|
-
steer: ->(text, source) { steer(text, source: source) }
|
|
1686
|
+
steer: ->(text, source) { steer(text, source: source) },
|
|
1687
|
+
turn_author: -> { turn_author }
|
|
1644
1688
|
)
|
|
1645
1689
|
end
|
|
1646
1690
|
private :build_hook_routes
|
|
@@ -2105,6 +2149,9 @@ module Samagotchi
|
|
|
2105
2149
|
turn_started[:resumed] = true if turn.resumed?
|
|
2106
2150
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
2107
2151
|
turn_started[:cut_after] = steer_cut_after
|
|
2152
|
+
# A participant's turn names them (author, author_name) for the UIs'
|
|
2153
|
+
# badge; the owner's turn_started stays as it was.
|
|
2154
|
+
turn_started.merge!(author_fields(turn.origin, session: session))
|
|
2108
2155
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2109
2156
|
raise image_error if image_error
|
|
2110
2157
|
|
|
@@ -2256,7 +2303,9 @@ module Samagotchi
|
|
|
2256
2303
|
# Inject due reminders as a tail system message (after history, before
|
|
2257
2304
|
# the new user prompt) to preserve prefix KV cache. Mutating the head
|
|
2258
2305
|
# system prompt invalidates cache for the entire prefix.
|
|
2259
|
-
|
|
2306
|
+
# Not into a participant's turn: the owner's reminders are the owner's,
|
|
2307
|
+
# and would be used up there under the review boundary.
|
|
2308
|
+
due_reminders = Steer.participant_author?(turn.origin&.dig(:author)) ? [] : @reminder_queue.inject!(turn.messages)
|
|
2260
2309
|
if due_reminders.any?
|
|
2261
2310
|
emit_event(turn.on_event, {
|
|
2262
2311
|
type: :reminder_injected,
|
|
@@ -2617,12 +2666,18 @@ module Samagotchi
|
|
|
2617
2666
|
end
|
|
2618
2667
|
|
|
2619
2668
|
# Restore the current session's conversation to a #messages_checkpoint.
|
|
2669
|
+
# Between turns the working set is announced when that changed it (a
|
|
2670
|
+
# !rollback or a canceled continue dropping the turn's tool calls, the
|
|
2671
|
+
# set emptied too), so the page doesn't keep the dropped items until
|
|
2672
|
+
# the next request.
|
|
2620
2673
|
# @param checkpoint [Array<Hash>]
|
|
2621
2674
|
# @return [Array<Hash>] the session's messages
|
|
2622
2675
|
def rollback_to(checkpoint)
|
|
2623
2676
|
raise ArgumentError, "no current session" unless @session
|
|
2624
2677
|
|
|
2625
|
-
replace_session_messages(@session, clone_messages(checkpoint))
|
|
2678
|
+
messages = replace_session_messages(@session, clone_messages(checkpoint))
|
|
2679
|
+
announce_working_set unless turn_running?
|
|
2680
|
+
messages
|
|
2626
2681
|
end
|
|
2627
2682
|
|
|
2628
2683
|
# Clone a messages array (shallow dup of each element).
|
|
@@ -2826,7 +2881,8 @@ module Samagotchi
|
|
|
2826
2881
|
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2827
2882
|
frontend: -> { interface },
|
|
2828
2883
|
cost: -> { @metrics.cost },
|
|
2829
|
-
usage: -> { @metrics.usage }
|
|
2884
|
+
usage: -> { @metrics.usage },
|
|
2885
|
+
turn_author: -> { turn_author }
|
|
2830
2886
|
)
|
|
2831
2887
|
end
|
|
2832
2888
|
|
|
@@ -113,11 +113,21 @@ module Samagotchi
|
|
|
113
113
|
|
|
114
114
|
# The gate's core checks, in order. A delegate child's boundary comes
|
|
115
115
|
# before the rules, so its ask keeps the child-boundary rule id (and
|
|
116
|
-
# its once/session scopes) when a strict rule asks too.
|
|
116
|
+
# its once/session scopes) when a strict rule asks too. The review
|
|
117
|
+
# boundary comes before both: a participant's turn is denied what it
|
|
118
|
+
# may not do, never asked about it (deny is sticky, so no later vote
|
|
119
|
+
# can turn it into a question).
|
|
117
120
|
def checks
|
|
118
121
|
rules = self.rules
|
|
119
|
-
[@failures, rules.hook_asks, protected_paths,
|
|
120
|
-
child_boundary, rules].compact
|
|
122
|
+
[@failures, review_boundary, rules.hook_asks, protected_paths,
|
|
123
|
+
(@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A participant's turn's boundary (Guardrails::ReviewBoundary): in
|
|
127
|
+
# every session, a no-op unless the running turn's author is a
|
|
128
|
+
# participant's.
|
|
129
|
+
def review_boundary
|
|
130
|
+
@review_boundary ||= Guardrails::ReviewBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
|
|
121
131
|
end
|
|
122
132
|
|
|
123
133
|
# The delegate child's boundary (Guardrails::ChildBoundary); nil for
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "review_paths"
|
|
4
|
+
require_relative "../toolkit"
|
|
5
|
+
require_relative "../steer"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Guardrails
|
|
9
|
+
# A participant's turn may only read (D3, D4: it proposes, the owner
|
|
10
|
+
# applies). A core check, on in every guardrails mode and with
|
|
11
|
+
# guardrails.enabled: false, active only while the running turn's author
|
|
12
|
+
# is a participant (GuardrailWiring#checks); the owner's turns never get
|
|
13
|
+
# it. It denies, never asks (nobody on the participant's side may
|
|
14
|
+
# approve, and the owner's approval would be the participant's text
|
|
15
|
+
# acting with the owner's power), every call outside the review kit
|
|
16
|
+
# (Toolkit::REVIEW_TOOLS). Not forget_outputs: it would replace the
|
|
17
|
+
# owner's earlier tool outputs with a note of the participant's, which
|
|
18
|
+
# later owner turns read as their own output, with no review header.
|
|
19
|
+
# read: ReviewPaths: only under the session's project root, never a file of
|
|
20
|
+
# the read denylist (chi's state and config dirs, home secret dirs,
|
|
21
|
+
# project secret files), checked on the path as given and as it
|
|
22
|
+
# resolves (a symlink in the project to ~/.ssh is ~/.ssh);
|
|
23
|
+
# memory_read: the project scope only, named (no fallback to system);
|
|
24
|
+
# anything else, call_tool's real target included: a call_tool of a
|
|
25
|
+
# built-in reaches the gate as that built-in (ToolRunner#unwrapped),
|
|
26
|
+
# so what is left of call_tool is a plugin or MCP tool, denied in v1.
|
|
27
|
+
# The declared tools stay the session's (no prompt rebuild per author),
|
|
28
|
+
# so a participant turn's model may still try a write: it is denied
|
|
29
|
+
# here, with the header's "This turn may only read" to keep it rare.
|
|
30
|
+
class ReviewBoundary
|
|
31
|
+
RULE = "review-boundary"
|
|
32
|
+
SOURCE = "core"
|
|
33
|
+
ALLOWED = Toolkit::REVIEW_TOOLS
|
|
34
|
+
PROJECT_SCOPE = "project"
|
|
35
|
+
|
|
36
|
+
ADVICE = "This is a participant's review turn: answer from what you can read and propose any change as " \
|
|
37
|
+
"text or a diff for the owner to apply."
|
|
38
|
+
NOT_IN_KIT = "a participant's review turn may only read (read, memory_read): %s is not allowed"
|
|
39
|
+
OUTSIDE = "a participant's review turn reads only inside the session's project"
|
|
40
|
+
SECRET = "a participant's review turn can't read secrets or chi's own files"
|
|
41
|
+
HOME_ROOT = "a participant's review turn reads only inside a project, and this session's folder is the " \
|
|
42
|
+
"home folder or above it"
|
|
43
|
+
READ_REFUSALS = { ReviewPaths::OUTSIDE => OUTSIDE, ReviewPaths::SECRET => SECRET, ReviewPaths::HOME_ROOT => HOME_ROOT }.freeze
|
|
44
|
+
MEMORY_SCOPE = "a participant's review turn reads project memories only: call memory_read with scope: project"
|
|
45
|
+
|
|
46
|
+
# @param author [#call] → String, nil: the running turn's author
|
|
47
|
+
# (GuardrailWiring#turn_author); the check is off unless it is a
|
|
48
|
+
# participant's (Steer.participant_author?)
|
|
49
|
+
# @param root [#call] → String, nil: the session's project root (the
|
|
50
|
+
# work tree top of its folder); nil denies every read
|
|
51
|
+
# @param denied_dirs [#call] → Array<String>: chi's own dirs (state,
|
|
52
|
+
# config: sessions, grants, the web token, memories and .bundles,
|
|
53
|
+
# config.yml, hooks)
|
|
54
|
+
# @param home [#call] → String, nil
|
|
55
|
+
def initialize(author:, root:, denied_dirs: -> { ReviewPaths.chi_dirs }, home: -> { Dir.home })
|
|
56
|
+
@author = author
|
|
57
|
+
@root = root
|
|
58
|
+
@denied_dirs = denied_dirs
|
|
59
|
+
@home = home
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Vote on +verdict+ (its call and targets).
|
|
63
|
+
def check(verdict)
|
|
64
|
+
return verdict unless Steer.participant_author?(@author.call)
|
|
65
|
+
|
|
66
|
+
reason = refusal(verdict)
|
|
67
|
+
return verdict unless reason
|
|
68
|
+
|
|
69
|
+
verdict.deny!(reason, rule: RULE, source: SOURCE, decided_by: "core", advice: ADVICE)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def refusal(verdict)
|
|
75
|
+
call = verdict.call || {}
|
|
76
|
+
tool = call[:name].to_s
|
|
77
|
+
return format(NOT_IN_KIT, tool_label(tool, verdict.targets)) unless ALLOWED.include?(tool)
|
|
78
|
+
|
|
79
|
+
case tool
|
|
80
|
+
when "read" then read_refusal(verdict.targets&.paths || [])
|
|
81
|
+
when "memory_read" then MEMORY_SCOPE unless call[:scope].to_s.strip.downcase == PROJECT_SCOPE
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A plugin or MCP tool behind call_tool names what it acts as.
|
|
86
|
+
def tool_label(tool, targets)
|
|
87
|
+
acts_as = targets&.acts_as
|
|
88
|
+
acts_as && acts_as != tool ? "#{tool} (#{acts_as})" : tool
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_refusal(paths)
|
|
92
|
+
paths_check = ReviewPaths.new(root: @root.call, denied_dirs: @denied_dirs.call, home: @home.call)
|
|
93
|
+
READ_REFUSALS[paths_check.refusal(paths)]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_paths"
|
|
4
|
+
require_relative "shell_paths"
|
|
5
|
+
require_relative "../paths"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Guardrails
|
|
9
|
+
# What a participant may read (the shared-sessions plan's §2): a file
|
|
10
|
+
# under the session's project root that isn't on the read denylist
|
|
11
|
+
# (chi's state and config dirs, home secret dirs, project secret files),
|
|
12
|
+
# checked on the path as given and as it resolves (a symlink in the
|
|
13
|
+
# project to ~/.ssh is ~/.ssh). ReviewBoundary asks it for every read of
|
|
14
|
+
# a participant's turn; a review session's subjects (chi review, the
|
|
15
|
+
# participant page's Plan) go through the same check.
|
|
16
|
+
class ReviewPaths
|
|
17
|
+
# Home folders that hold keys and tokens, never read in a review turn.
|
|
18
|
+
HOME_SECRET_DIRS = %w[.ssh .gnupg .aws].freeze
|
|
19
|
+
# Project files that hold secrets, by a path part's name: .env*,
|
|
20
|
+
# netrc, keys and certificates, anything named credentials, package
|
|
21
|
+
# registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
|
|
22
|
+
# secrets file; and a git config (.git/config, a submodule's
|
|
23
|
+
# .git/modules/x/config), checked apart.
|
|
24
|
+
SECRET_FILE = /\A(?:\.env.*|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
|
|
25
|
+
\.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
|
|
26
|
+
# The .docker/config.json kind: a dot dir's config holding tokens.
|
|
27
|
+
SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
|
|
28
|
+
GIT_DIR = ".git"
|
|
29
|
+
|
|
30
|
+
# Why a path can't be read (#refusal).
|
|
31
|
+
OUTSIDE = :outside
|
|
32
|
+
SECRET = :secret
|
|
33
|
+
HOME_ROOT = :home_root
|
|
34
|
+
|
|
35
|
+
# chi's state dir and config dir, as Paths has them now.
|
|
36
|
+
def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
|
|
37
|
+
|
|
38
|
+
# @param root [String, nil] the session's project root (the work tree
|
|
39
|
+
# top of its folder); nil or blank refuses every path
|
|
40
|
+
# @param denied_dirs [Array<String>] chi's own dirs (state, config:
|
|
41
|
+
# sessions, grants, the web token, memories and .bundles,
|
|
42
|
+
# config.yml, hooks)
|
|
43
|
+
# @param home [String, nil]
|
|
44
|
+
def initialize(root:, denied_dirs: ReviewPaths.chi_dirs, home: Dir.home)
|
|
45
|
+
@root = root.to_s
|
|
46
|
+
@denied_dirs = denied_dirs
|
|
47
|
+
@home = home.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Why +paths+ can't all be read, or nil when they can: OUTSIDE (no
|
|
51
|
+
# path, no root, or a path outside the root), HOME_ROOT (the root is
|
|
52
|
+
# the home folder or above it) or SECRET.
|
|
53
|
+
# @param paths [Array<String>] absolute, or relative to the cwd
|
|
54
|
+
# @return [Symbol, nil]
|
|
55
|
+
def refusal(paths)
|
|
56
|
+
return OUTSIDE if paths.empty? || @root.empty?
|
|
57
|
+
|
|
58
|
+
real_root = ProtectedPaths.real(@root)
|
|
59
|
+
return HOME_ROOT if !@home.empty? && ShellPaths.within?(ProtectedPaths.real(@home), real_root)
|
|
60
|
+
|
|
61
|
+
paths.each do |path|
|
|
62
|
+
given = File.expand_path(path)
|
|
63
|
+
real = ProtectedPaths.real(given)
|
|
64
|
+
return OUTSIDE unless ShellPaths.within?(real, real_root)
|
|
65
|
+
return SECRET if secret?(given) || secret?(real)
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def secret?(path)
|
|
73
|
+
dirs = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
|
|
74
|
+
dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
|
|
75
|
+
dirs += HOME_SECRET_DIRS.map { |dir| File.join(@home, dir) } unless @home.empty?
|
|
76
|
+
return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
|
|
77
|
+
|
|
78
|
+
parts = path.split(File::SEPARATOR)
|
|
79
|
+
return true if parts.any? { |part| SECRET_FILE.match?(part) }
|
|
80
|
+
return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
|
|
81
|
+
|
|
82
|
+
git_config?(parts)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A config file anywhere under a .git dir (.git/config,
|
|
86
|
+
# .git/modules/<sub>/config, a worktree's config.worktree).
|
|
87
|
+
def git_config?(parts)
|
|
88
|
+
git = parts.index { |part| part.casecmp?(GIT_DIR) } or return false
|
|
89
|
+
parts[(git + 1)..].any? { |part| part.downcase.start_with?("config") }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
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
|
-
|
|
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
|
|