samagotchi 0.85.0 → 0.86.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +34 -5
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +38 -3
  6. data/docs/configuration.md +6 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +19 -3
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +1 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +6 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge.rb +22 -11
  23. data/lib/samagotchi/bridge_client.rb +2 -2
  24. data/lib/samagotchi/bundle_command/check.rb +348 -0
  25. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  26. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  27. data/lib/samagotchi/bundle_command.rb +125 -2
  28. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  31. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  32. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  33. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  34. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  35. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  36. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  37. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  38. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  39. data/lib/samagotchi/client_id.rb +1 -1
  40. data/lib/samagotchi/config.rb +195 -115
  41. data/lib/samagotchi/context_status.rb +1 -1
  42. data/lib/samagotchi/continue_offer.rb +2 -1
  43. data/lib/samagotchi/engine.rb +4 -4
  44. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  45. data/lib/samagotchi/guardrails/approval.rb +12 -2
  46. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  47. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  48. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  49. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  50. data/lib/samagotchi/hook_routes.rb +5 -3
  51. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  52. data/lib/samagotchi/hooks/registry.rb +13 -6
  53. data/lib/samagotchi/hooks.rb +5 -0
  54. data/lib/samagotchi/kernel_loop.rb +5 -2
  55. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  56. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/parent_report.rb +87 -14
  59. data/lib/samagotchi/pending_question.rb +8 -4
  60. data/lib/samagotchi/plugin/api.rb +16 -0
  61. data/lib/samagotchi/plugin/context.rb +11 -5
  62. data/lib/samagotchi/plugin/loader.rb +5 -3
  63. data/lib/samagotchi/question_desk.rb +37 -24
  64. data/lib/samagotchi/queued_command.rb +5 -4
  65. data/lib/samagotchi/relay.rb +5 -1
  66. data/lib/samagotchi/relay_verifier.rb +4 -5
  67. data/lib/samagotchi/reply_wait.rb +14 -5
  68. data/lib/samagotchi/send_command.rb +34 -8
  69. data/lib/samagotchi/session_commands.rb +14 -3
  70. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  71. data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -11
  72. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  73. data/lib/samagotchi/terminal_ui.rb +12 -9
  74. data/lib/samagotchi/testing/boundary.rb +0 -3
  75. data/lib/samagotchi/testing/bundle.rb +7 -0
  76. data/lib/samagotchi/testing/host.rb +6 -4
  77. data/lib/samagotchi/testing/install.rb +119 -0
  78. data/lib/samagotchi/testing/recorder.rb +13 -4
  79. data/lib/samagotchi/testing.rb +1 -0
  80. data/lib/samagotchi/tools/builtins.rb +2 -1
  81. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  82. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  83. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  84. data/lib/samagotchi/turn_flow.rb +10 -0
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +48 -10
  87. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  88. data/lib/samagotchi/web/public/ctx.js +12 -26
  89. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  90. data/lib/samagotchi/web/server.rb +4 -0
  91. data/lib/samagotchi/worker.rb +19 -5
  92. data/lib/samagotchi/worker_inbound.rb +4 -4
  93. metadata +11 -2
  94. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -15,6 +15,7 @@ module Samagotchi
15
15
  KIND = "approval"
16
16
  HEADER = "Approve tool call?"
17
17
  DENY = "Deny"
18
+ ONCE = "Allow once"
18
19
 
19
20
  module_function
20
21
 
@@ -27,7 +28,9 @@ module Samagotchi
27
28
  # write: the card shows its diff, the question text one change line
28
29
  # @param only [Array<String>, nil] offer no scope beyond these (a
29
30
  # scratch session: once, session); "once" when none is left
30
- # @return [Hash] open_question fields
31
+ # @return [Hash] open_question fields; its caller adds user_only:
32
+ # (ParentApprovals.protected_facts? on approval:, a file that
33
+ # requires this one)
31
34
  def payload(verdict, label: nil, preview: nil, only: nil)
32
35
  scopes = offered_scopes(verdict, only: only)
33
36
  targets = verdict.targets
@@ -37,6 +40,7 @@ module Samagotchi
37
40
  header: HEADER,
38
41
  multi_select: false,
39
42
  allow_freeform: true,
43
+ effects: effects(scopes),
40
44
  kind: KIND,
41
45
  approval: {
42
46
  tool: targets&.tool || verdict.call[:name].to_s,
@@ -61,6 +65,12 @@ module Samagotchi
61
65
  }
62
66
  end
63
67
 
68
+ # Each option's effect for a parent's answer (ParentPolicy): once
69
+ # permits one call, a wider scope more, Deny denies.
70
+ def effects(scopes)
71
+ scopes.map { |scope| scope == "once" ? "permit" : "permit_wide" } + ["deny"]
72
+ end
73
+
64
74
  def args_for_card(targets)
65
75
  return nil if targets.nil? || targets.command || !targets.paths.empty?
66
76
 
@@ -101,7 +111,7 @@ module Samagotchi
101
111
  def label(scope, verdict)
102
112
  place = verdict.targets&.repo_root ? "in this repo (#{verdict.targets.repo_name})" : "in this directory"
103
113
  case scope
104
- when "once" then "Allow once"
114
+ when "once" then ONCE
105
115
  when "session" then "Allow this call for the session"
106
116
  when "repo" then "Allow this call #{place}"
107
117
  when "rule" then "Allow rule #{verdict.rule} #{place}"
@@ -7,7 +7,18 @@ module Samagotchi
7
7
  # guardrail that silently vanished is the failure this guards against);
8
8
  # the rest are only announced.
9
9
  class LoadFailures
10
- Failure = Struct.new(:what, :reason, :required, :group, keyword_init: true)
10
+ # +location+: "path:line" of the first frame of the error inside the
11
+ # bundle that failed (nil when none is), for `chi bundle check`.
12
+ Failure = Struct.new(:what, :reason, :required, :group, :location, keyword_init: true)
13
+
14
+ # "path:line" where +error+ was raised inside +dir+ (its first
15
+ # backtrace frame there; a SyntaxError's own file:line), or nil.
16
+ def self.location(error, dir)
17
+ prefix = "#{File.expand_path(dir)}/"
18
+ frame = Array(error.backtrace).find { |line| line.start_with?(prefix) }
19
+ frame ||= error.message.lines.first if error.is_a?(SyntaxError)
20
+ frame&.start_with?(prefix) ? frame[/\A[^:]+:\d+/] : nil
21
+ end
11
22
 
12
23
  def initialize
13
24
  @list = []
@@ -16,8 +27,11 @@ module Samagotchi
16
27
 
17
28
  # @param what [String] e.g. "hook guard.rb (config)"
18
29
  # @param group [Symbol, nil] what loads it again (#drop): :rules
19
- def add(what, reason, required:, group: nil)
20
- @mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required, group: group) }
30
+ # @param location [String, nil] Failure#location
31
+ def add(what, reason, required:, group: nil, location: nil)
32
+ @mutex.synchronize do
33
+ @list << Failure.new(what: what, reason: reason, required: required, group: group, location: location)
34
+ end
21
35
  end
22
36
 
23
37
  # Forget a group's failures before it loads again.
@@ -7,29 +7,18 @@ require_relative "../hooks/loader"
7
7
  require_relative "approval"
8
8
  require_relative "verdict"
9
9
  require_relative "protected_paths"
10
- require_relative "parent_continue"
11
10
 
12
11
  module Samagotchi
13
12
  module Guardrails
14
- # What a parent agent may allow on an approval (guardrails.parent_approvals,
15
- # config.yml only): off, a deny only; once, "Allow once" too, never a
16
- # wider scope. `chi answer` checks it before it posts; the worker checks
17
- # it again for an answer marked as chi answer's (CLIENT_ID), with its own
18
- # config. A convention for an honest but eager parent, not a security
19
- # boundary: any local process can post to the Bridge, with or without
20
- # the marker.
21
- #
22
- # An option allows when Approval.settle would allow it: its index is
23
- # below the approval's scopes. The scope is read by index, never by
24
- # label. When the scopes are missing or don't fit the options, it fails
25
- # closed: only a last option labelled Deny denies.
26
- #
27
- # Whatever the setting, an approval for a call on chi's own config dir
28
- # (config.yml, memories/.bundles' rules), the hooks dir or the approval
29
- # store is the user's alone (:protected): "Allow once" on a config.yml
30
- # rewrite would be "allow everything" from then on.
13
+ # The facts a parent agent's answer is judged by (ParentPolicy): who
14
+ # answers as a parent (#parent_process?), what an approval is
15
+ # (#approval?, its #scopes) and which approvals are the user's alone
16
+ # (#protected?): a call on chi's own config dir (config.yml,
17
+ # memories/.bundles' rules), the hooks dir or the approval store, since
18
+ # "Allow once" on a config.yml rewrite would be "allow everything" from
19
+ # then on. Also chi's dirs and text for the guardrails' own rules
20
+ # (Targets, the shell rules).
31
21
  module ParentApprovals
32
- KEY = "guardrails.parent_approvals"
33
22
  # Set by chi's execute and task_create for the commands they run: a
34
23
  # chi started there answers as a parent (#parent_process?).
35
24
  PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
@@ -56,7 +45,7 @@ module Samagotchi
56
45
  module_function
57
46
 
58
47
  # Whether an answer typed into this process is a parent agent's (so
59
- # this setting applies to it): stdin isn't a terminal (a pipe, a file,
48
+ # ParentPolicy applies to it): stdin isn't a terminal (a pipe, a file,
60
49
  # /dev/null), or an agent marker is set. A person at chi --attach or
61
50
  # the REPL has a terminal and no marker.
62
51
  def parent_process?(env: ENV, stdin: $stdin)
@@ -65,35 +54,14 @@ module Samagotchi
65
54
  !(stdin.respond_to?(:tty?) && stdin.tty?)
66
55
  end
67
56
 
68
- # This process's setting: "once" or "off" (anything else is off).
69
- def setting
70
- Config.get(KEY).to_s == "once" ? "once" : "off"
71
- end
72
-
73
- # Why a parent may not give this answer, or nil.
74
- # @param pending [Hash] the pending question (symbol or string keys)
75
- # @param indices [Array<Integer, nil>] the selected options' places
76
- # @param setting [String, nil] "off" or "once"
77
- # @return [Symbol, nil] :protected (chi's own config: the user's
78
- # alone), :off (no allow at all), :once_only, or nil
79
- def refusal(pending, indices, setting:)
80
- return nil unless approval?(pending)
81
-
82
- scopes = scopes(pending)
83
- allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
84
- return nil if allows.empty?
85
- return :protected if protected?(pending)
86
- return :off unless setting.to_s == "once"
87
- return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
88
-
89
- :once_only
90
- end
91
-
92
57
  # Whether +pending+ asks about a call on chi's own config, hooks,
93
58
  # guardrail rules or approvals: by its rule, its paths or its command
94
59
  # (the dirs as this process's environment and config.yml name them).
95
- def protected?(pending)
96
- facts = pending.approval
60
+ def protected?(pending) = protected_facts?(pending.approval)
61
+
62
+ # #protected? on an approval's facts (Approval.payload's approval:),
63
+ # at ask time.
64
+ def protected_facts?(facts)
97
65
  return false unless facts.is_a?(Hash)
98
66
  return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
99
67
  return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
@@ -120,21 +88,6 @@ module Samagotchi
120
88
  .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
121
89
  end
122
90
 
123
- # The refusal as a line for the parent: deny, and tell the user.
124
- # Never how to allow it (the web, chi --attach): that is the user's.
125
- # @param reason [Symbol] what #refusal returned
126
- # @param typed [Boolean] the answer was typed at a ? prompt (the
127
- # REPL), not given to chi answer
128
- def message(reason, typed: false)
129
- deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
130
- case reason
131
- when :stop_only then ParentContinue.message
132
- when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
133
- when :off then "allowing a tool call is up to the user: #{deny}"
134
- else "only Allow once (#{KEY}: once) can be given here: #{deny}"
135
- end
136
- end
137
-
138
91
  # An approval: its kind says so, or it carries approval facts.
139
92
  def approval?(pending)
140
93
  pending.kind.to_s == Approval::KIND || !pending.approval.nil?
@@ -151,15 +104,6 @@ module Samagotchi
151
104
  scopes
152
105
  end
153
106
 
154
- def deny?(pending, scopes, index)
155
- return false unless index.is_a?(Integer) && index >= 0
156
-
157
- options = options(pending)
158
- return index >= scopes.size && index < options.size if scopes
159
-
160
- index == options.size - 1 && options[index].to_s == Approval::DENY
161
- end
162
-
163
107
  def options(pending) = Array(pending.options)
164
108
 
165
109
  def fetch(hash, key)
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../pending_question"
5
+ require_relative "approval"
6
+ require_relative "parent_approvals"
7
+
8
+ module Samagotchi
9
+ module Guardrails
10
+ # What a parent agent may answer on a session's question: one check for
11
+ # every asker (an approval, the step-limit Continue, a plugin's or a
12
+ # hook's ask_user, a relayed delegate approval). The asker tags each
13
+ # option with its effect (PendingQuestion#effects); a strategy says
14
+ # which effects a parent may pick. `chi answer` checks it before it
15
+ # posts; the worker's QuestionDesk checks it again, with its own config,
16
+ # for an answer marked as a parent agent's. A convention for an honest
17
+ # but eager parent, not a security boundary.
18
+ #
19
+ # Strategies (guardrails.parent_answers, config.yml only): hard = answer
20
+ # and deny; standard = + spend; soft (the default) = + permit.
21
+ #
22
+ # Effects: answer (an untagged option), deny, spend (more time and
23
+ # tokens: Continue), permit (one call: Allow once), permit_wide (more
24
+ # than one call: a session, repo or rule scope). A parent never picks
25
+ # permit_wide; on a user_only question (chi's own config, hooks or
26
+ # guardrails) it picks only deny or answer. Text alone and a dismissal
27
+ # always pass.
28
+ module ParentPolicy
29
+ ANSWER = "answer"
30
+ DENY = "deny"
31
+ SPEND = "spend"
32
+ PERMIT = "permit"
33
+ PERMIT_WIDE = "permit_wide"
34
+ EFFECTS = [ANSWER, DENY, SPEND, PERMIT, PERMIT_WIDE].freeze
35
+ # The effects a parent may pick with no setting at all.
36
+ ALWAYS = [ANSWER, DENY].freeze
37
+ # Beyond-the-strategy reasons, widest first (#refusal).
38
+ WIDEST_FIRST = [PERMIT_WIDE, PERMIT, SPEND].freeze
39
+
40
+ # The step-limit question (ContinueOffer).
41
+ CONTINUE_KIND = "continue"
42
+ CONTINUE = "Continue"
43
+ STOP = "Stop"
44
+ # Its shape, for a /continue yes sent as a command (#continue_refusal).
45
+ CONTINUE_QUESTION = PendingQuestion.new(kind: CONTINUE_KIND, options: [CONTINUE, STOP], effects: [SPEND, DENY])
46
+
47
+ KEY = "guardrails.parent_answers"
48
+ HARD = "hard"
49
+ STANDARD = "standard"
50
+ SOFT = "soft"
51
+
52
+ # A strategy: its name and the effects a parent may pick under it.
53
+ Strategy = Data.define(:name, :allowed) do
54
+ def allow?(effect) = allowed.include?(effect.to_s)
55
+
56
+ # How a refusal names the setting: "guardrails.parent_answers: hard".
57
+ def label = "#{KEY}: #{name}"
58
+ end
59
+
60
+ STRATEGIES = {
61
+ HARD => Strategy.new(name: HARD, allowed: ALWAYS),
62
+ STANDARD => Strategy.new(name: STANDARD, allowed: [*ALWAYS, SPEND].freeze),
63
+ SOFT => Strategy.new(name: SOFT, allowed: [*ALWAYS, SPEND, PERMIT].freeze)
64
+ }.freeze
65
+
66
+ module_function
67
+
68
+ # This process's strategy (config.yml only; Config makes an invalid
69
+ # value hard and warns).
70
+ # @return [Strategy]
71
+ def current = strategy(Config.get(KEY))
72
+
73
+ # The named strategy; anything else is hard, the strictest.
74
+ # @return [Strategy]
75
+ def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
76
+
77
+ # @return [Array<String>] the effects +strategy+ lets a parent pick
78
+ def allowed_effects(strategy) = strategy.allowed
79
+
80
+ # Each option's effect, in option order. Tagged questions carry them;
81
+ # an untagged one (asked by an older worker, a relayed card built
82
+ # from an older child's question) gets them derived as before:
83
+ # an approval's from its scopes, the step-limit question's from its
84
+ # labels, anything else's all answer.
85
+ # @param pending [PendingQuestion]
86
+ # @return [Array<String>]
87
+ def effects_of(pending)
88
+ options = Array(pending.options)
89
+ tags = pending.effects
90
+ return options.each_index.map { |index| tag(tags[index]) } if tags.is_a?(Array)
91
+ return approval_effects(pending, options) if ParentApprovals.approval?(pending)
92
+ return options.map { |label| continue_effect(label) } if pending.kind.to_s == CONTINUE_KIND
93
+
94
+ options.map { ANSWER }
95
+ end
96
+
97
+ # Whether only the user may pick a non-deny/answer option. Unset on an
98
+ # approval (an older worker's): worked out from its facts here. A
99
+ # relayed card: the child's flag, or this process's own look at it.
100
+ def user_only?(pending)
101
+ return true if pending.user_only == true
102
+ return false unless ParentApprovals.approval?(pending)
103
+ return false if pending.user_only == false && !pending.relay
104
+
105
+ ParentApprovals.protected?(pending)
106
+ end
107
+
108
+ # Why a parent may not give this answer, or nil.
109
+ # @param pending [PendingQuestion]
110
+ # @param indices [Array<Integer, nil>] the selected options' places
111
+ # @param strategy [Strategy]
112
+ # @return [Symbol, nil] :user_only, :permit_wide, :permit or :spend
113
+ def refusal(pending, indices, strategy:)
114
+ effects = effects_of(pending)
115
+ picked = Array(indices).map { |index| effect_at(effects, index) }
116
+ return nil if picked.empty?
117
+ return :user_only if user_only?(pending) && picked.any? { |effect| !ALWAYS.include?(effect) }
118
+
119
+ WIDEST_FIRST.each do |effect|
120
+ return effect.to_sym if picked.include?(effect) && !strategy.allow?(effect)
121
+ end
122
+ nil
123
+ end
124
+
125
+ # The refusal as a line for the parent: what to answer instead, or
126
+ # tell the user. Never how to allow it (the web, chi --attach): that
127
+ # is the user's.
128
+ # @param reason [Symbol] what #refusal returned
129
+ # @param typed [Boolean] the answer was typed at a ? prompt (the
130
+ # REPL), not given to chi answer
131
+ def message(reason, pending:, strategy:, typed: false)
132
+ deny = deny_label(pending)
133
+ case reason.to_sym
134
+ when :spend
135
+ if pending.kind.to_s == CONTINUE_KIND
136
+ return "a parent agent may only stop this turn here (#{strategy.label}): answer #{STOP} " \
137
+ "(--option #{STOP} --text WHY), or tell your user it waits"
138
+ end
139
+
140
+ "a parent agent may not pick #{tagged_labels(pending, SPEND)} here (#{strategy.label}): #{instead(deny, typed, "answer")}"
141
+ when :user_only
142
+ "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{instead(deny, typed)}"
143
+ when :permit_wide
144
+ # An approval whose scopes don't fit its options has no permit
145
+ # option: its once is still what a parent may give.
146
+ permits = tagged_labels(pending, PERMIT)
147
+ permits = Approval::ONCE if permits.empty? && ParentApprovals.approval?(pending)
148
+ if strategy.allow?(PERMIT) && !permits.empty?
149
+ "only #{permits} (#{strategy.label}) can be given here: #{instead(deny, typed)}"
150
+ else
151
+ "#{allowing(pending)} is up to the user: #{instead(deny, typed)}"
152
+ end
153
+ else "#{allowing(pending)} is up to the user: #{instead(deny, typed)}"
154
+ end
155
+ end
156
+
157
+ # Why a parent agent may not continue a turn with /continue yes (a
158
+ # command: chi send -m, a piped attach), or nil: the step-limit
159
+ # question's Continue, checked the same way.
160
+ # @return [String, nil] the refusal line
161
+ def continue_refusal(strategy: current)
162
+ reason = refusal(CONTINUE_QUESTION, [0], strategy: strategy)
163
+ reason && message(reason, pending: CONTINUE_QUESTION, strategy: strategy)
164
+ end
165
+
166
+ # The labels a parent may pick on +pending+ under +strategy+.
167
+ # @return [Array<String>]
168
+ def allowed_options(pending, strategy:)
169
+ user_only = user_only?(pending)
170
+ Array(pending.options).zip(effects_of(pending)).filter_map do |label, effect|
171
+ next nil if user_only && !ALWAYS.include?(effect)
172
+
173
+ label.to_s if strategy.allow?(effect)
174
+ end
175
+ end
176
+
177
+ # The question's deny-tagged option's label, or nil.
178
+ def deny_label(pending)
179
+ index = effects_of(pending).index(DENY)
180
+ index && Array(pending.options)[index].to_s
181
+ end
182
+
183
+ # What the parent does instead: deny with the question's own deny
184
+ # option and tell the user, or (none) tell the user it waits.
185
+ def instead(deny, typed, verb = "deny it")
186
+ return "tell your user it waits" unless deny
187
+
188
+ how = typed ? "n; WHY" : "--option #{deny} --text WHY"
189
+ verb == "answer" ? "answer #{deny} (#{how}), or tell your user it waits" : "#{verb} (#{how}), and tell your user"
190
+ end
191
+
192
+ def allowing(pending)
193
+ ParentApprovals.approval?(pending) ? "allowing a tool call" : "allowing it"
194
+ end
195
+
196
+ def tagged_labels(pending, effect)
197
+ Array(pending.options).zip(effects_of(pending)).filter_map { |label, tag| label.to_s if tag == effect }.join(" or ")
198
+ end
199
+
200
+ # An index it can't place counts as the widest effect (fail closed).
201
+ def effect_at(effects, index)
202
+ return PERMIT_WIDE unless index.is_a?(Integer) && index >= 0 && index < effects.size
203
+
204
+ effects[index]
205
+ end
206
+
207
+ # A tag as given; an unknown one fails closed.
208
+ def tag(value)
209
+ return ANSWER if value.nil?
210
+
211
+ EFFECTS.include?(value.to_s) ? value.to_s : PERMIT_WIDE
212
+ end
213
+
214
+ # A plugin's or a hook's effects (label => effect, ask_user's
215
+ # effects:) as the options' tags, in option order; a label left out
216
+ # is answer (QuestionDesk#ask_for_hook, and the test kit's Recorder).
217
+ # @return [Array<String>, nil, false] the tags; nil for none given;
218
+ # false when invalid (not a Hash, a label the options don't have,
219
+ # an effect not in EFFECTS)
220
+ def option_tags(options, effects)
221
+ return nil if effects.nil?
222
+ return false unless effects.is_a?(Hash)
223
+
224
+ given = effects.to_h { |label, effect| [label.to_s, effect.to_s] }
225
+ return false unless (given.keys - Array(options).map(&:to_s)).empty?
226
+ return false unless (given.values - EFFECTS).empty?
227
+
228
+ Array(options).map { |label| given.fetch(label.to_s, ANSWER) }
229
+ end
230
+
231
+ # When the scopes fit the options: once permits, another scope
232
+ # permits wide, the options past them deny. When they don't (missing,
233
+ # malformed): only a last option labelled Deny denies.
234
+ def approval_effects(pending, options)
235
+ scopes = ParentApprovals.scopes(pending)
236
+ if scopes
237
+ return options.each_index.map do |index|
238
+ next DENY if index >= scopes.size
239
+
240
+ scopes[index] == "once" ? PERMIT : PERMIT_WIDE
241
+ end
242
+ end
243
+
244
+ options.each_with_index.map do |label, index|
245
+ index == options.size - 1 && label.to_s == Approval::DENY ? DENY : PERMIT_WIDE
246
+ end
247
+ end
248
+
249
+ def continue_effect(label)
250
+ case label.to_s
251
+ when CONTINUE then SPEND
252
+ when STOP then DENY
253
+ else ANSWER
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -16,10 +16,13 @@ module Samagotchi
16
16
  # command source for attached context (it runs later, ungated); chi
17
17
  # broadcast, which is the user's; the bundle's asks before a memory
18
18
  # that reaches the system prompt, or a model overlay, is written (a
19
- # parent model would be writing a model's prompt). Only the user may
20
- # allow them (ParentApprovals).
19
+ # parent model would be writing a model's prompt); and the bundle's
20
+ # asks before answering chi's questions around chi answer (a parent
21
+ # allowing one would answer a grandchild's question as the user).
22
+ # Only the user may allow them (ParentApprovals.protected?,
23
+ # ParentPolicy's user_only).
21
24
  PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
22
- model-overlay-write].freeze
25
+ model-overlay-write chi-answer-piped chi-answer-http].freeze
23
26
  SCOPES = %w[once session repo rule].freeze
24
27
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
25
28
 
@@ -36,7 +36,9 @@ module Samagotchi
36
36
  def runtime
37
37
  Hooks::Runtime.new(
38
38
  notify: ->(text:, level:, hook:, fallback_for: nil) { notify(text, level, hook, fallback_for: fallback_for) },
39
- ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { ask_user(question, options, header, allow_freeform, hook) },
39
+ ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
40
+ ask_user(question, options, header, allow_freeform, hook, effects: effects)
41
+ },
40
42
  stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
41
43
  steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
42
44
  stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
@@ -86,8 +88,8 @@ module Samagotchi
86
88
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
87
89
  # answer, cancelled) is nil too.
88
90
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
89
- def ask_user(question, options, header, allow_freeform, hook)
90
- @question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook)
91
+ def ask_user(question, options, header, allow_freeform, hook, effects: nil)
92
+ @question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook, effects: effects)
91
93
  end
92
94
 
93
95
  # Cancel the running turn (reason :hook), after a notice that says why.
@@ -3,6 +3,7 @@
3
3
  require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
+ require_relative "../guardrails/load_failures"
6
7
  require_relative "../memory_bundle/bundle_hook"
7
8
  require_relative "../memory_bundle/provenance"
8
9
  require_relative "../memory_bundle/manifest"
@@ -90,7 +91,8 @@ module Samagotchi
90
91
  raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
91
92
 
92
93
  Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
93
- failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed)
94
+ failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed,
95
+ location: Guardrails::LoadFailures.location(e, File.dirname(hooks_dir)))
94
96
  end
95
97
  end
96
98
  loaded
@@ -111,8 +111,8 @@ module Samagotchi
111
111
  # callables, each given the hook's label. +notify+ takes
112
112
  # (text:, level:, hook:, and fallback_for: when the hook gave one: what
113
113
  # the line stands in for, HookRoutes#notify) and shows one line to the
114
- # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:) and
115
- # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
114
+ # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:, and
115
+ # effects: when the hook gave them) and returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
116
116
  # cancels the running turn (true when it did); +steer+ takes (text:,
117
117
  # hook:) and puts the text into the running turn (Engine#steer, true
118
118
  # when queued); +stop_generation+ takes (reason:, hook:) and cuts the
@@ -270,9 +270,11 @@ module Samagotchi
270
270
  # to the user; fallback_for: :display marks a line that stands in
271
271
  # for the answer's display (event[:present]), which a UI that
272
272
  # renders the display's links leaves out (HookRoutes#notify)
273
- # event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
273
+ # event[:ask_user].call(question:, options:, header: nil, allow_freeform: false, effects: nil)
274
274
  # -> {selected:, freeform:, selected_indices:} or nil (no one to
275
- # ask, cancelled, bad options)
275
+ # ask, cancelled, bad options or effects); effects: tags the
276
+ # options a parent agent may not simply pick, by label
277
+ # ({ "Run it" => :permit }, see Plugin::Context#ask_user)
276
278
  # event[:stop_turn].call(reason) -> true when the turn was cancelled;
277
279
  # in a before_tool_call event it also denies the call; from
278
280
  # after_turn / session_end it does nothing (false)
@@ -347,6 +349,10 @@ module Samagotchi
347
349
  end
348
350
  end
349
351
 
352
+ # The events bundle hooks are registered for (any symbol Api#on took).
353
+ # @return [Array<Symbol>]
354
+ def bundle_events = @mutex.synchronize { @bundle_hooks.keys }
355
+
350
356
  # @return [Integer] total number of registered hooks (bundle + plain)
351
357
  def size
352
358
  @mutex.synchronize do
@@ -383,9 +389,10 @@ module Samagotchi
383
389
  @runtime&.notify&.call(**notice)
384
390
  nil
385
391
  }
386
- event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false|
392
+ event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false, effects: nil|
387
393
  @runtime&.ask_user&.call(question: question, options: options, header: header,
388
- allow_freeform: allow_freeform, hook: event[:hook])
394
+ allow_freeform: allow_freeform, hook: event[:hook],
395
+ **(effects.nil? ? {} : { effects: effects }))
389
396
  }
390
397
  event[:stop_turn] ||= lambda { |reason|
391
398
  next false if TURN_OVER_EVENTS.include?(event[:type])
@@ -26,5 +26,10 @@ module Samagotchi
26
26
  # event also has context: and targets: (docs/guardrails.md)
27
27
  # :after_tool_call — after tool execution, before result injection
28
28
  module Hooks
29
+ # Every event a hook can be registered for, in a turn's order
30
+ # (docs/hooks.md). Plugin::Api#on takes any symbol: `chi bundle check`
31
+ # warns on one not here.
32
+ EVENTS = %i[session_start before_turn before_generation generation_progress after_generation before_tool_call
33
+ after_tool_call after_turn session_end].freeze
29
34
  end
30
35
  end
@@ -525,8 +525,11 @@ module Samagotchi
525
525
  end
526
526
 
527
527
  # The payload a :generation_chunk event carries: llama.cpp's last chunk
528
- # echoes the whole rendered prompt, which nothing reads and every SSE
529
- # client would get (the kernel's own reads keep the full payload).
528
+ # echoes the whole rendered prompt, which nothing reads. SSE clients get
529
+ # no payload at all (Bridge::ClientEvent), but the event is still written
530
+ # to the debug log and handed to in-process observers (the ring, which
531
+ # `?raw=1` replays, SessionMetrics, hooks), so the prompt stays out of
532
+ # it (the kernel's own reads keep the full payload).
530
533
  def event_payload(payload)
531
534
  payload.is_a?(Hash) ? payload.except("prompt") : payload
532
535
  end
@@ -102,7 +102,7 @@ module Samagotchi
102
102
 
103
103
  # +event+ as on_delta passes it on: one that carries usage gets the
104
104
  # usage so far in the OpenAI shape as its `usage` (SessionMetrics,
105
- # ContextStatus and ctx.js read that).
105
+ # ContextStatus and the Bridge's chunk `counts` read that).
106
106
  def with_usage(event)
107
107
  return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
108
108