samagotchi 0.79.0 → 0.80.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +7 -4
  6. data/docs/internals/anthropic.md +3 -1
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  29. data/lib/samagotchi/llm/http.rb +12 -7
  30. data/lib/samagotchi/plugin/card.rb +44 -0
  31. data/lib/samagotchi/plugin/context.rb +53 -8
  32. data/lib/samagotchi/plugin/http.rb +154 -0
  33. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  34. data/lib/samagotchi/plugin/loader.rb +29 -10
  35. data/lib/samagotchi/question_answer.rb +29 -0
  36. data/lib/samagotchi/question_desk.rb +20 -19
  37. data/lib/samagotchi/relay_verifier.rb +1 -1
  38. data/lib/samagotchi/testing/boundary.rb +156 -0
  39. data/lib/samagotchi/testing/bundle.rb +349 -0
  40. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  41. data/lib/samagotchi/testing/fake_http.rb +137 -0
  42. data/lib/samagotchi/testing/helpers.rb +56 -0
  43. data/lib/samagotchi/testing/host.rb +76 -0
  44. data/lib/samagotchi/testing/minitest.rb +31 -0
  45. data/lib/samagotchi/testing/recorder.rb +138 -0
  46. data/lib/samagotchi/testing/recording_api.rb +27 -0
  47. data/lib/samagotchi/testing/rspec.rb +31 -0
  48. data/lib/samagotchi/testing/sandbox.rb +163 -0
  49. data/lib/samagotchi/testing/sessions.rb +77 -0
  50. data/lib/samagotchi/testing/verdict.rb +28 -0
  51. data/lib/samagotchi/testing/waiting.rb +36 -0
  52. data/lib/samagotchi/testing.rb +26 -0
  53. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  54. data/lib/samagotchi/tools/tool_index.rb +3 -0
  55. data/lib/samagotchi/turn_state.rb +28 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/public/app.js +47 -23
  58. data/lib/samagotchi/web/public/images.js +33 -0
  59. data/lib/samagotchi/web/public/index.html +71 -38
  60. data/lib/samagotchi/web/public/level_chip.js +11 -11
  61. data/lib/samagotchi/web/public/lists_view.js +19 -14
  62. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  63. data/lib/samagotchi/web/public/model_picker.js +1 -1
  64. data/lib/samagotchi/web/public/scope.js +3 -3
  65. data/lib/samagotchi/web/public/strip.js +35 -9
  66. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  67. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  68. metadata +21 -1
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Testing
5
+ # What a bundle's Ruby may reach of chi (docs/plugins.md, "Stable for
6
+ # bundles"): ALLOWED is the checked copy of that list. #scan finds a
7
+ # bundle's other references to Samagotchi:: and requires of chi's files;
8
+ # EXCEPTIONS is what the shipped bundles reach today anyway, each with
9
+ # why: the APIs to design before that bundle could leave chi's repo
10
+ # (spec/samagotchi/testing/boundary_spec.rb keeps both exact).
11
+ module Boundary
12
+ # A name a bundle may use, and the file that defines it (a require of
13
+ # it is fine too). A name allows itself, its methods (".x") and what
14
+ # it holds ("::X"); one ending in a method or constant allows only that.
15
+ Allowed = Data.define(:name, :file)
16
+
17
+ ALLOWED = [
18
+ Allowed.new(name: "Samagotchi::Plugin::Api", file: "samagotchi/plugin/api"),
19
+ Allowed.new(name: "Samagotchi::Plugin::ToolResult", file: "samagotchi/plugin/tool_result"),
20
+ Allowed.new(name: "Samagotchi::Plugin::ModelError", file: "samagotchi/plugin/context"),
21
+ Allowed.new(name: "Samagotchi::Plugin::ModelCancelled", file: "samagotchi/plugin/context"),
22
+ Allowed.new(name: "Samagotchi::Plugin::HttpError", file: "samagotchi/plugin/http"),
23
+ Allowed.new(name: "Samagotchi::Plugin::HttpCancelled", file: "samagotchi/plugin/http"),
24
+ Allowed.new(name: "Samagotchi::Plugin::Http::Response", file: "samagotchi/plugin/http"),
25
+ Allowed.new(name: "Samagotchi::Plugin::Service::Stopped", file: "samagotchi/plugin/service"),
26
+ Allowed.new(name: "Samagotchi::Plugin::Sessions::Error", file: "samagotchi/plugin/sessions"),
27
+ Allowed.new(name: "Samagotchi::Plugin::AttachedContext::Error", file: "samagotchi/plugin/attached_context"),
28
+ Allowed.new(name: "Samagotchi::Plugin::Peer", file: "samagotchi/plugin/peer"),
29
+ Allowed.new(name: "Samagotchi::Tools::ToolIndex::Tool", file: "samagotchi/tools/tool_index"),
30
+ Allowed.new(name: "Samagotchi::Tools::ToolIndex::Group", file: "samagotchi/tools/tool_index"),
31
+ Allowed.new(name: "Samagotchi::Tools::ToolIndex.schema_note", file: "samagotchi/tools/tool_index"),
32
+ Allowed.new(name: "Samagotchi::Tools::ToolIndex.spec_tokens", file: "samagotchi/tools/tool_index"),
33
+ Allowed.new(name: "Samagotchi::Tools::Execute::NAME", file: "samagotchi/tools/execute"),
34
+ Allowed.new(name: "Samagotchi::Tools::Execute::TIMEOUT_PREFIX", file: "samagotchi/tools/execute"),
35
+ Allowed.new(name: "Samagotchi::Tools::Execute::HANDED_OVER", file: "samagotchi/tools/execute"),
36
+ Allowed.new(name: "Samagotchi::Tools::Execute.timeout_seconds", file: "samagotchi/tools/execute"),
37
+ Allowed.new(name: "Samagotchi::Tools::TaskRuntime::TASKS_DIR", file: "samagotchi/tools/task_runtime"),
38
+ Allowed.new(name: "Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME", file: "samagotchi/tools/task_runtime"),
39
+ Allowed.new(name: "Samagotchi::AtomicFile.write", file: "samagotchi/atomic_file"),
40
+ Allowed.new(name: "Samagotchi::Log.warn", file: "samagotchi/log"),
41
+ Allowed.new(name: "Samagotchi::Log.debug", file: "samagotchi/log"),
42
+ Allowed.new(name: "Samagotchi::Config.get", file: "samagotchi/config"),
43
+ Allowed.new(name: "Samagotchi::VERSION", file: "samagotchi/version"),
44
+ # The kit itself, for a bundle's own specs.
45
+ Allowed.new(name: "Samagotchi::Testing", file: "samagotchi/testing")
46
+ ].freeze
47
+
48
+ # The kit's adapters, required by a bundle's specs.
49
+ ALLOWED_REQUIRES = (ALLOWED.map(&:file) + %w[samagotchi/testing/rspec samagotchi/testing/minitest]).uniq.freeze
50
+
51
+ SESSION = "a saved session (context sources are keyed by its id): needs a kit helper that saves one"
52
+ SOURCES = "the session's context sources (the peer's inbox / outbox): needs a kit helper that attaches one"
53
+ REF_LIMITS = "a ref's kinds and limits (event[:refs] checks them too): could be stable constants"
54
+ # The peers' specs pair a session through its context sources.
55
+ PEER_SPEC = {
56
+ 'require "samagotchi/context_sources"' => "spec: #{SOURCES}",
57
+ "Samagotchi::Session.new_session" => "spec: #{SESSION}",
58
+ "Samagotchi::ContextSources.session_location" => "spec: #{SOURCES}",
59
+ "Samagotchi::ContextSources::Source.new" => "spec: #{SOURCES}",
60
+ "Samagotchi::ContextSources::Fetched.new" => "spec: an outbox answer as the context poller records it"
61
+ }.freeze
62
+
63
+ # bundle => {reference => why}: what each shipped bundle reaches
64
+ # beyond ALLOWED today, its specs' (why "spec: …") included.
65
+ EXCEPTIONS = {
66
+ "agents-md" => {
67
+ 'require "samagotchi/guardrails/context"' => "for GitInfo: a ctx helper for the checkout's files could replace it",
68
+ "Samagotchi::Guardrails::GitInfo.new" => "the work tree's top for the AGENTS.md chain (ctx.repo_root covers the top only)"
69
+ },
70
+ "claude-peer" => PEER_SPEC,
71
+ "codex-peer" => PEER_SPEC,
72
+ "coordinator" => {
73
+ "Samagotchi::MemoryPaths.scope_dir" => "where the coordinator's memories live: needs a ctx memory-path API",
74
+ "Samagotchi::MemoryPaths" => "spec: checks /coordinate end reads no memories (MemoryPaths.scope_dir isn't called)"
75
+ },
76
+ "github-pr" => {
77
+ "Samagotchi::Session.new_session" => "spec: #{SESSION} (ctx.delegate?, ctx.fork?, ctx.context read it)",
78
+ "Samagotchi::ContextSources.session_location" => "spec: reads and removes the session's sources (added_by), " \
79
+ "which ctx.context.list doesn't show",
80
+ 'require "samagotchi/memory_bundle/installer"' => "spec: for Installer",
81
+ "Samagotchi::MemoryBundle::Installer.new" => "spec: installs the bundle so its context provider resolves: " \
82
+ "needs a kit install helper",
83
+ 'require "samagotchi/context_providers"' => "spec: for ContextProviders",
84
+ "Samagotchi::ContextProviders.resolve" => "spec: the manifest's URL provider through chi's own resolver",
85
+ 'require "samagotchi/answer_display"' => "spec: for AnswerDisplay",
86
+ "Samagotchi::AnswerDisplay.new" => "spec: after_turn's present: helper, as chi's fire site builds it: " \
87
+ "the kit could supply after_turn's fire-site keys",
88
+ 'require "samagotchi/answer_refs"' => "spec: for AnswerRefs",
89
+ "Samagotchi::AnswerRefs.new" => "spec: after_turn's refs: helper, as chi's fire site builds it",
90
+ "Samagotchi::Log" => "spec: stubs Log to check what ctx.log recorded: a kit log recorder could replace it"
91
+ },
92
+ "loop-guard" => {
93
+ 'require "samagotchi/llm/native_tool_normalizer"' => "spec: for NativeToolNormalizer",
94
+ "Samagotchi::LLM::NativeToolNormalizer.normalize" => "spec: replays recorded calls in the shape chi's parser gives",
95
+ "Samagotchi::Hooks::StreamWatch::EVERY_CHARS" => "spec: the stream watch's batch size (a ctx or kit value later)"
96
+ },
97
+ "skills" => {
98
+ "Samagotchi::Tools::MemoryRead.memories_dir" => "lists the skill memories: needs a ctx memory-path API"
99
+ },
100
+ "mcp" => {
101
+ 'require "samagotchi/process_group"' => "for ProcessGroup.stop",
102
+ "Samagotchi::ProcessGroup.stop" => "stops a server's process group: needs a ctx process API",
103
+ 'require "samagotchi/token_usage"' => "for CHARS_PER_TOKEN",
104
+ "Samagotchi::TokenUsage::CHARS_PER_TOKEN" => "estimates a tool's tokens: could be a stable constant",
105
+ 'require "samagotchi/tool_declarations"' => "loaded for Api.tool_spec's schemas"
106
+ },
107
+ "slow-commands" => {
108
+ 'require "samagotchi/guardrails/shell_lex"' => "for ShellLex",
109
+ "Samagotchi::Guardrails::ShellLex" => "splits a command into its parts: needs a stable shell-parsing helper",
110
+ "Samagotchi::Tools::Execute.const_defined?" => "reads execute's limits that older chis lack, by reflection",
111
+ "Samagotchi::Tools::Execute.const_get" => "reads execute's limits that older chis lack, by reflection"
112
+ },
113
+ "source-links" => {
114
+ 'require "samagotchi/ref"' => "for the Ref / RefAction limits",
115
+ "Samagotchi::Ref::FILE" => REF_LIMITS,
116
+ "Samagotchi::Ref::KIND.match?" => REF_LIMITS,
117
+ "Samagotchi::Ref::MAX_LABEL" => REF_LIMITS,
118
+ "Samagotchi::RefAction::MAX_LABEL" => REF_LIMITS,
119
+ "Samagotchi::RefAction.http_url?" => "which URLs an action may open: could be a stable helper"
120
+ }
121
+ }.freeze
122
+
123
+ # A Samagotchi:: name with what follows it (::X, .method).
124
+ REFERENCE = /\bSamagotchi(?:::[A-Z]\w*)+(?:\.[a-z_]\w*[?!]?)?/
125
+ # A require of one of chi's files.
126
+ REQUIRE = %r{\brequire\s*\(?\s*["'](samagotchi(?:/[\w/]+)?)["']}
127
+
128
+ # One reference outside ALLOWED, in a file of the bundle.
129
+ Finding = Data.define(:reference, :file)
130
+
131
+ module_function
132
+
133
+ # The bundle's references to chi outside ALLOWED (its plugin, hooks,
134
+ # scripts and specs: every .rb under +dir+), one per reference and
135
+ # file, sorted.
136
+ # @param dir [String] the bundle's folder
137
+ # @return [Array<Finding>]
138
+ def scan(dir)
139
+ Dir.glob(File.join(dir, "**", "*.rb")).sort.flat_map do |path|
140
+ text = File.read(path)
141
+ refs = text.scan(REFERENCE).reject { |name| allowed?(name) }
142
+ refs += text.scan(REQUIRE).flatten.reject { |file| ALLOWED_REQUIRES.include?(file) }.map { |file| %(require "#{file}") }
143
+ file = path.delete_prefix("#{File.expand_path(dir)}/")
144
+ refs.uniq.map { |reference| Finding.new(reference: reference, file: file) }
145
+ end
146
+ end
147
+
148
+ # Whether a bundle may use +name+ ("Samagotchi::X::Y", maybe ".method").
149
+ def allowed?(name)
150
+ ALLOWED.any? do |entry|
151
+ name == entry.name || name.start_with?("#{entry.name}.", "#{entry.name}::")
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../plugin/loader"
4
+ require_relative "../plugin/tool_spec"
5
+ require_relative "../hooks"
6
+ require_relative "../commands/registry"
7
+ require_relative "../session_commands"
8
+ require_relative "../tools/builtins"
9
+ require_relative "../tools/tool_index"
10
+ require_relative "../peer_sources"
11
+ require_relative "../toolkit"
12
+ require_relative "../guardrails/gate"
13
+ require_relative "../memory_bundle/manifest"
14
+ require_relative "sandbox"
15
+ require_relative "recorder"
16
+ require_relative "host"
17
+ require_relative "sessions"
18
+ require_relative "fake_http"
19
+ require_relative "verdict"
20
+ require_relative "recording_api"
21
+
22
+ module Samagotchi
23
+ module Testing
24
+ # A hook (a plugin's chi.on block or a hook file) raised while the kit
25
+ # fired it: chi would log it and go on, a test fails with it.
26
+ class HookFailed < StandardError
27
+ # @return [Hooks::Failure]
28
+ attr_reader :failure
29
+
30
+ def initialize(failure)
31
+ @failure = failure
32
+ error = failure.error
33
+ super("#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}")
34
+ set_backtrace(error.backtrace) if error.backtrace
35
+ end
36
+
37
+ def cause = @failure.error
38
+ end
39
+
40
+ # A chi.init the plugin registered: its arguments and block.
41
+ Init = Data.define(:label, :plugin_label, :provides_tools, :quiet, :timeout, :failed, :block)
42
+ # A chi.replace_tools set, staged as chi stages it for the turn thread.
43
+ StagedTools = Data.define(:specs, :context)
44
+
45
+ # One bundle loaded for a test through chi's own seams: its plugin
46
+ # through Plugin::Loader.register_file (Plugin::Api, a real
47
+ # Plugin::Context) and its hook files through
48
+ # Hooks::BundleLoader.load_hook_file, into real registries (commands
49
+ # and tools with chi's built-ins in them, so a name clash fails as in
50
+ # chi). Only the edges are recorded: the Host and hook Runtime
51
+ # (Recorder), ctx.sessions (Testing::Sessions), chi.init tasks and
52
+ # staged tool sets. No sha256 or requires_chi check: it loads the
53
+ # source as it is.
54
+ #
55
+ # bundle = Samagotchi::Testing::Bundle.load("path/to/bundle", settings: { "every" => 3 })
56
+ # bundle.fire(:after_turn, status: "completed")
57
+ # bundle.command("/checkin stop")
58
+ # bundle.tool_call({ name: "execute", content: "ls" }) # → Verdict
59
+ # bundle.notices, bundle.cards, bundle.steers …
60
+ # bundle.shutdown
61
+ #
62
+ # A hook that raises while #fire, #tool_call, #command or #tool runs
63
+ # raises HookFailed after the call (chi would log it and go on);
64
+ # #lenient collects them in #errors instead.
65
+ class Bundle
66
+ SHIPPED_DIR = File.expand_path("../bundles", __dir__)
67
+
68
+ class << self
69
+ # @param dir [String] the bundle's folder (its manifest.yml)
70
+ # @param settings [Hash] config.yml `bundles: <name>:`
71
+ # @param host [Hash] session values (Recorder::DEFAULTS' keys)
72
+ # @param sandbox [Sandbox, nil] default Sandbox.current, else one of
73
+ # its own, left at #shutdown
74
+ # @return [Bundle]
75
+ def load(dir, settings: {}, host: {}, sandbox: nil)
76
+ new(dir, settings: settings, host: host, sandbox: sandbox).tap(&:load!)
77
+ end
78
+
79
+ # A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
80
+ def shipped(name, **) = load(File.join(SHIPPED_DIR, name.to_s), **)
81
+ end
82
+
83
+ # @return [String] the bundle's name (manifest.yml)
84
+ attr_reader :name
85
+ # @return [MemoryBundle::Manifest]
86
+ attr_reader :manifest
87
+ # @return [Plugin::Registries]
88
+ attr_reader :registries
89
+ # @return [Recorder]
90
+ attr_reader :recorder
91
+ # @return [Sessions] ctx.sessions
92
+ attr_reader :sessions
93
+ # @return [FakeHttp] what answers ctx.http (stub, requests, unmatched)
94
+ attr_reader :http
95
+ # @return [Sandbox]
96
+ attr_reader :sandbox
97
+ # @return [Plugin::Api, nil] the plugin's committed Api (nil without one)
98
+ attr_reader :api
99
+ # @return [Object, nil] the plugin's instance (the object #register ran
100
+ # on), for specs of its own methods
101
+ attr_reader :plugin
102
+ # @return [Module, nil] the module the plugin was evaluated in
103
+ # (bundle.namespace::ThinkingWatch)
104
+ attr_reader :namespace
105
+ # @return [Module] the module the hook files were evaluated in
106
+ attr_reader :hooks_namespace
107
+ # @return [Array<Init>]
108
+ attr_reader :inits
109
+ # @return [Array<StagedTools>]
110
+ attr_reader :staged_tools
111
+ # @return [Array<Hooks::Failure>] what raised under #lenient
112
+ attr_reader :errors
113
+ # @return [Integer] how often chi was told the tools changed
114
+ attr_reader :tools_changes
115
+
116
+ def initialize(dir, settings: {}, host: {}, sandbox: nil)
117
+ @dir = File.expand_path(dir)
118
+ @manifest = MemoryBundle::Manifest.read(dir: @dir)
119
+ @name = @manifest.name
120
+ @settings = stringify(settings || {})
121
+ @own_sandbox = sandbox.nil? && Sandbox.current.nil?
122
+ @sandbox = sandbox || Sandbox.current || Sandbox.enter
123
+ @recorder = Recorder.new(cwd: @sandbox.work_dir, **host)
124
+ @sessions = Sessions.new
125
+ @http = FakeHttp.new
126
+ @inits = []
127
+ @staged_tools = []
128
+ @errors = []
129
+ @lenient = false
130
+ @tools_changes = 0
131
+ @contexts = {}
132
+ @registries = build_registries
133
+ end
134
+
135
+ # @api private (Bundle.load)
136
+ def load!
137
+ if (plugin = @manifest.plugin)
138
+ @namespace = Plugin::Loader.fresh_namespace(@name)
139
+ @api = Plugin::Loader.register_file(@name, File.join(@dir, plugin[:file]), @registries, settings: @settings,
140
+ namespace: @namespace,
141
+ api_class: RecordingApi) do |built|
142
+ @plugin = built
143
+ end
144
+ end
145
+ @hooks_namespace = Hooks::BundleLoader.namespace_for(@name).const_set(:"KitHooks#{object_id}", Module.new)
146
+ @manifest.hooks.each do |basename, hook|
147
+ next if hook.event.empty?
148
+
149
+ Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
150
+ settings: @settings, namespace: @hooks_namespace)
151
+ end
152
+ self
153
+ rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
154
+ shutdown
155
+ raise
156
+ end
157
+
158
+ # ── Firing ──────────────────────────────────────────────────────────
159
+
160
+ # Fire hook +event+ through the registry, as chi's fire sites do: the
161
+ # event is {type: event, **fields} (a fire site's own keys, such as
162
+ # after_turn's present:/refs:, come from +fields+), with the runtime
163
+ # helpers added. Returns the event after the hooks.
164
+ # @return [Hash]
165
+ def fire(event, **fields)
166
+ payload = { type: event.to_sym, **fields }
167
+ failing { hooks.fire(event.to_sym, payload) }
168
+ payload
169
+ end
170
+
171
+ # One tool call through a real Guardrails::Gate: the before_tool_call
172
+ # hooks vote on a Testing::Verdict (pass +verdict+ to start from one,
173
+ # another voter's deny in it), with the context and targets the Gate
174
+ # computes for the call (cwd: the session's by default).
175
+ # @param call [Hash] the parsed call ({name:, content:/path:/args:})
176
+ # @param context [Guardrails::Context, nil]
177
+ # @return [Verdict]
178
+ def tool_call(call, params: nil, cwd: nil, context: nil, verdict: nil, iteration: 1)
179
+ context ||= Guardrails::Context.new(cwd: cwd || @recorder[:cwd], session_id: @recorder[:session_id],
180
+ interface: @recorder[:frontend])
181
+ gate = Guardrails::Gate.new(-> { hooks }, context_lookup: -> { context }, tools_lookup: -> { tools },
182
+ checks_lookup: -> { [] })
183
+ verdict ||= Verdict.new(call: call)
184
+ params ||= "#{call[:name]} #{call[:content] || call[:path]}".strip
185
+ failing { gate.evaluate(call, iteration: iteration, params: params, verdict: verdict) }
186
+ end
187
+
188
+ # Run a command line ("/name args") as chi does: the first matching
189
+ # command, given the text after its name. Returns the handler's value
190
+ # (a raise propagates: chi would show it as an error result).
191
+ def command(line)
192
+ entry = commands.lookup(line)
193
+ raise ArgumentError, "no command matches #{line.inspect}" unless entry
194
+ raise ArgumentError, "#{entry.name} is chi's own command, not the bundle's" if entry.source == "core"
195
+
196
+ failing { entry.handler.call(line.to_s.strip.delete_prefix(entry.name).strip) }
197
+ end
198
+
199
+ # Call a registered tool as chi's dispatch does: its handler gets the
200
+ # call ({name:, args:}), types the args by the tool's schema
201
+ # (ToolSpec.args_of) and returns the text the model reads.
202
+ # @return [String]
203
+ def tool(name, args = {})
204
+ entry = tools[name.to_s]
205
+ raise ArgumentError, "no tool #{name}" unless entry
206
+
207
+ failing { entry.handler.call({ name: name.to_s, args: stringify(args) }, nil) }
208
+ end
209
+
210
+ # Run the chi.init blocks in order, as chi's init tasks do.
211
+ # @return [Array] their results
212
+ def run_inits = failing { @inits.map { |init| init.block.call } }
213
+
214
+ # Apply the staged chi.replace_tools sets, as chi does before a turn
215
+ # (ToolSpec.apply). @return [Boolean] whether the tools changed
216
+ def apply_staged_tools!
217
+ sets = @staged_tools.dup
218
+ @staged_tools.clear
219
+ sets.map { |set| Plugin::ToolSpec.apply(tools, @name, set.specs, set.context)[:changed] }.any?
220
+ end
221
+
222
+ # Collect what raises in the block's fires in #errors instead of
223
+ # raising HookFailed.
224
+ def lenient
225
+ was = @lenient
226
+ @lenient = true
227
+ yield
228
+ ensure
229
+ @lenient = was
230
+ end
231
+
232
+ # ── What it registered ─────────────────────────────────────────────
233
+
234
+ # @return [Commands::Registry] chi's built-ins and the bundle's
235
+ def commands = @registries.commands
236
+ # @return [Tools::Registry] chi's built-ins and the bundle's
237
+ def tools = @registries.tools
238
+ # @return [Hooks::Registry]
239
+ def hooks = @registries.hooks
240
+ # @return [Array<Toolkit::Kit>]
241
+ def toolkits = @registries.toolkits
242
+ # @return [Tools::ToolIndex]
243
+ def tool_index = @registries.tool_index
244
+ # @return [PeerSources]
245
+ def peer_sources = @registries.peer_sources
246
+
247
+ # The bundle's own command entries.
248
+ def bundle_commands = commands.entries.select { |entry| entry.source == @name }
249
+ # The bundle's own tool entries.
250
+ def bundle_tools = tools.by_source(@name)
251
+ # Its hooks for +event+ as [bundle, hook_name, priority], fire order.
252
+ def hook_names(event) = hooks.bundle_hook_names(event.to_sym)
253
+ # The annotations its commands carry.
254
+ def annotations = bundle_commands.flat_map(&:annotations)
255
+
256
+ # The chi.* calls #register made, in order, as made (RecordingApi).
257
+ # @return [Array<Declaration>]
258
+ def declarations = @api ? @api.declarations : []
259
+
260
+ # The plugin's ctx (a real Plugin::Context; nil without a plugin).
261
+ def ctx = @contexts.values.first
262
+
263
+ # ── Recorders ──────────────────────────────────────────────────────
264
+
265
+ def notices = @recorder.notices
266
+ def cards = @recorder.cards
267
+ def steers = @recorder.steers
268
+ # Every stop_turn and stop_generation, in order (Stop#kind).
269
+ def stops = @recorder.stops
270
+ def turn_stops = @recorder.turn_stops
271
+ def generation_stops = @recorder.generation_stops
272
+ def questions = @recorder.questions
273
+ def model_requests = @recorder.model_requests
274
+ def toolkit_uses = @recorder.toolkit_uses
275
+ # Queue ask_model answers: a String, an exception, or a callable.
276
+ def model_answers = @recorder.model_answers
277
+
278
+ # Queue ask_user answers ({selected:, freeform:, selected_indices:},
279
+ # or a callable given the Question).
280
+ def answer(*answers)
281
+ @recorder.answers.concat(answers)
282
+ self
283
+ end
284
+
285
+ # Change the session's values (cwd:, messages:, running: …).
286
+ def set(**values)
287
+ @recorder.set(**values)
288
+ self
289
+ end
290
+
291
+ # Stop the plugin's services; leave the sandbox it entered.
292
+ def shutdown
293
+ @registries&.services&.stop_all
294
+ return unless @own_sandbox
295
+
296
+ @own_sandbox = false
297
+ @sandbox.leave
298
+ end
299
+
300
+ private
301
+
302
+ def build_registries
303
+ builtin_tools = Tools::Builtins.registry
304
+ toolkits = []
305
+ host = Host.build(@recorder, toolkits: -> { Toolkit::BUILTIN_KITS.keys + toolkits.map(&:name) },
306
+ tool_names: -> { @registries.tools.names })
307
+ Plugin::Registries.new(
308
+ commands: SessionCommands.register_builtins(Commands::Registry.new),
309
+ tools: builtin_tools,
310
+ hooks: Hooks::Registry.new.tap { |registry| registry.runtime = Runtime.build(@recorder) },
311
+ context_for: lambda { |bundle, settings, label|
312
+ @contexts[label] = Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host,
313
+ env: ENV, sessions: @sessions, http_transport: @http)
314
+ },
315
+ tools_changed: -> { @tools_changes += 1 },
316
+ services: Plugin::Services.new,
317
+ stage_tools: ->(_bundle, specs, context) { @staged_tools << StagedTools.new(specs: specs, context: context) },
318
+ init: lambda { |_bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
319
+ @inits << Init.new(label: label, plugin_label: plugin_label, provides_tools: provides_tools, quiet: quiet,
320
+ timeout: timeout, failed: failed, block: block)
321
+ },
322
+ toolkits: toolkits,
323
+ tool_index: Tools::ToolIndex.new,
324
+ peer_sources: PeerSources.new
325
+ )
326
+ end
327
+
328
+ # Run the block catching the hooks that raise in it.
329
+ def failing(&)
330
+ failures = []
331
+ result = Hooks.capture_failures(failures, &)
332
+ if @lenient
333
+ @errors.concat(failures)
334
+ elsif (first = failures.first)
335
+ raise HookFailed, first
336
+ end
337
+ result
338
+ end
339
+
340
+ def stringify(value)
341
+ case value
342
+ when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
343
+ when Array then value.map { |item| stringify(item) }
344
+ else value
345
+ end
346
+ end
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Samagotchi
7
+ module Testing
8
+ # Fake commands for a test's PATH (Sandbox#fake_executable), cheap to
9
+ # exec: macOS checks a new executable file on its first exec (about
10
+ # 0.5–1 s, once per file). A fake is a symlink to one launcher, made
11
+ # once per process, that runs the fake's body (<name>.sh beside the
12
+ # link) with /bin/sh: writing a new body costs nothing.
13
+ module FakeExecutable
14
+ LAUNCHER = "#!/bin/sh\nexec /bin/sh \"$0.sh\" \"$@\"\n"
15
+
16
+ module_function
17
+
18
+ # Writes +dir+/+name+, which runs +body+ (sh) with its arguments;
19
+ # again for the same name replaces the body.
20
+ # @return [String] the command's path
21
+ def write(dir, name, body)
22
+ FileUtils.mkdir_p(dir)
23
+ path = File.join(dir, name)
24
+ File.write("#{path}.sh", body.end_with?("\n") ? body : "#{body}\n")
25
+ FileUtils.ln_sf(launcher, path)
26
+ path
27
+ end
28
+
29
+ def launcher
30
+ @launcher ||= begin
31
+ dir = Dir.mktmpdir("chi-fake-exec")
32
+ owner = Process.pid
33
+ at_exit { FileUtils.rm_rf(dir) if Process.pid == owner }
34
+ File.join(dir, "launcher").tap do |path|
35
+ File.write(path, LAUNCHER)
36
+ File.chmod(0o755, path)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end