samagotchi 0.91.0 → 0.93.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -18,11 +18,18 @@ module Samagotchi
18
18
  class TaskWatch
19
19
  # A watched task: +limit_sec+, the limit an execute handed it over at
20
20
  # (nil for task_create's); +note_only+, the user stopped the turn that
21
- # started or waited on it (M5): its finish is a plain note, no wake.
22
- Entry = Data.define(:id, :limit_sec, :note_only) do
23
- def initialize(id:, limit_sec: nil, note_only: false) = super
21
+ # started or waited on it (M5): its finish is a plain note, no wake;
22
+ # +failures+, how often its note couldn't be written (#put_back) or the
23
+ # look for it failed (#look_failed).
24
+ Entry = Data.define(:id, :limit_sec, :note_only, :failures) do
25
+ def initialize(id:, limit_sec: nil, note_only: false, failures: 0) = super
24
26
  end
25
27
 
28
+ # A task whose note or look failed this often still gets tried at each
29
+ # look, but no longer holds the worker up (#holding): a failure that
30
+ # never passes must not keep it alive for good.
31
+ MAX_FAILURES = 3
32
+
26
33
  # The cancel reasons that are the user's own Stop (Bridge::CANCEL_REASONS
27
34
  # less session_stopped, and the TUI's ctrl_c).
28
35
  USER_CANCEL_REASONS = %i[manual user ctrl_c].freeze
@@ -36,11 +43,18 @@ module Samagotchi
36
43
  MODE_KEY = "session.task_wakes"
37
44
  MODES = %w[wake queue off].freeze
38
45
 
39
- # session.task_wakes: wake (default), queue or off; anything else is wake.
46
+ # session.task_wakes: wake (default), queue or off; anything else is
47
+ # wake, and so is a config that can't be read (logged once per error
48
+ # class: it is read at every idle pass).
40
49
  def self.mode
41
50
  value = Config.get(MODE_KEY).to_s
42
51
  MODES.include?(value) ? value : "wake"
43
- rescue StandardError
52
+ rescue StandardError => e
53
+ @mode_errors ||= {}
54
+ unless @mode_errors[e.class]
55
+ @mode_errors[e.class] = true
56
+ Log.warn(:worker, "task_wakes_unreadable", error: e.class.name, msg: e.message)
57
+ end
44
58
  "wake"
45
59
  end
46
60
 
@@ -94,7 +108,9 @@ module Samagotchi
94
108
 
95
109
  def ids = @mutex.synchronize { @entries.keys }
96
110
 
97
- def entry(id) = @mutex.synchronize { @entries[id] }
111
+ # The watched tasks, oldest first (a copy).
112
+ # @return [Array<Entry>]
113
+ def entries = @mutex.synchronize { @entries.values }
98
114
 
99
115
  def any? = @mutex.synchronize { @entries.any? }
100
116
 
@@ -107,10 +123,10 @@ module Samagotchi
107
123
  # running task (TaskRuntime.get_record); nothing with an empty watch.
108
124
  # @return [Array<Array(Entry, Tools::TaskRecord)>]
109
125
  def take_finished(keep: nil)
110
- entries = @mutex.synchronize { @entries.values }
111
- return [] if entries.empty?
126
+ watched = entries
127
+ return [] if watched.empty?
112
128
 
113
- entries.filter_map do |entry|
129
+ watched.filter_map do |entry|
114
130
  # Safe for any llm_context strategy: stale and forget stubs are drawn
115
131
  # in the view sent to the model (LLMContextView, the entry's edits),
116
132
  # never written over the saved tool_response text; only a !rollback
@@ -118,7 +134,10 @@ module Samagotchi
118
134
  next drop(entry, log: "not_in_conversation") if keep && !keep.include?(entry.id)
119
135
 
120
136
  record, error = Tools::TaskRuntime.get_record(entry.id)
121
- next drop(entry) if error || record.nil?
137
+ if error || record.nil?
138
+ # Gone (its task dir deleted) or unreadable: no note can be written.
139
+ next drop(entry, log: "record_unreadable", level: :info, error: error.to_s.lines.first&.strip)
140
+ end
122
141
  next nil if record.running?
123
142
  # Removed by a look meanwhile (a task_wait on an anytime command's thread).
124
143
  next nil unless drop(entry, kept: true)
@@ -129,13 +148,25 @@ module Samagotchi
129
148
  end
130
149
 
131
150
  # +entries+ taken by #take_finished whose note couldn't be written:
132
- # watched again (an id watched meanwhile keeps its own entry).
151
+ # watched again, the failure counted (an id watched meanwhile keeps its
152
+ # own entry).
133
153
  def put_back(entries)
134
- @mutex.synchronize { entries.each { |entry| @entries[entry.id] ||= entry } }
154
+ @mutex.synchronize do
155
+ entries.each { |entry| @entries[entry.id] ||= entry.with(failures: entry.failures + 1) }
156
+ end
157
+ end
158
+
159
+ # The look for finished tasks failed before it could tell which ended
160
+ # (the conversation's task ids couldn't be read): every watched task
161
+ # counts a failure.
162
+ def look_failed
163
+ @mutex.synchronize { @entries.transform_values! { |entry| entry.with(failures: entry.failures + 1) } }
135
164
  end
136
165
 
137
166
  # Whether a watched task has finished (#take_finished would take it):
138
- # Engine#next_turn_waiting asks at a turn's end. Takes nothing.
167
+ # Engine#next_turn_waiting asks at a turn's end. Takes nothing. A record
168
+ # that can't be read counts as not finished here (and not running in
169
+ # #running): #take_finished drops it at the next look, logged.
139
170
  def finished?
140
171
  ids.any? do |id|
141
172
  record, = Tools::TaskRuntime.get_record(id)
@@ -143,23 +174,35 @@ module Samagotchi
143
174
  end
144
175
  end
145
176
 
146
- # The watched tasks still running ({id:, command:}), for the idle exit
147
- # and /exit (WorkerIdleExit's running_tasks).
177
+ # The watched tasks a finish would wake for, running or finished and
178
+ # not yet taken (#take_finished takes them at the idle loop's next
179
+ # look): they hold the worker's idle exit (WorkerIdleExit's
180
+ # watched_tasks). Not a note-only one (no wake would come for it), nor
181
+ # one whose note or look failed MAX_FAILURES times. No `ps`: in memory.
182
+ # @return [Array<Entry>]
183
+ def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
184
+
185
+ # The #holding tasks still running ({id:, command:}), for /exit
186
+ # (WorkerIdleExit's watched_running): one finished meanwhile is noted
187
+ # as the worker leaves. One `ps` per task.
148
188
  # @return [Array<Hash>]
149
189
  def running
150
- ids.filter_map do |id|
151
- record, = Tools::TaskRuntime.get_record(id)
152
- { id: id, command: record.command.to_s } if record&.running?
190
+ holding.filter_map do |entry|
191
+ record, = Tools::TaskRuntime.get_record(entry.id)
192
+ { id: entry.id, command: record.command.to_s } if record&.running?
153
193
  end
154
194
  end
155
195
 
156
196
  private
157
197
 
158
198
  # Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
159
- # was there. +log+: why a task leaves without a note (debug).
160
- def drop(entry, kept: false, log: nil)
199
+ # was there. +log+: why a task leaves without a note, at +level+ (debug
200
+ # by default), with +error+ when there is one.
201
+ def drop(entry, kept: false, log: nil, level: :debug, error: nil)
161
202
  removed = !remove(entry.id).nil?
162
- Log.debug(:worker, "task_watch_dropped", task_id: entry.id, reason: log) if log && removed
203
+ if log && removed
204
+ Log.public_send(level, :worker, "task_watch_dropped", **{ task_id: entry.id, reason: log, error: error }.compact)
205
+ end
163
206
  kept ? removed : nil
164
207
  end
165
208
 
@@ -1639,9 +1639,9 @@ module Samagotchi
1639
1639
  end
1640
1640
 
1641
1641
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
1642
- def report_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CHILD_PREFIX)
1643
- def context_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CONTEXT_PREFIX)
1644
- def task_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::TASK_PREFIX)
1642
+ def report_origin?(origin) = ClientId.origin(origin[:client_id]) == "delegate_report"
1643
+ def context_origin?(origin) = ClientId.origin(origin[:client_id]) == "context"
1644
+ def task_origin?(origin) = ClientId.origin(origin[:client_id]) == "task"
1645
1645
  # A turn with no prompt: "(continuing)"; a context wake's or a task
1646
1646
  # finish wake's label alone.
1647
1647
  def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
@@ -215,11 +215,14 @@ module Samagotchi
215
215
  return fallback unless client_id
216
216
 
217
217
  id = client_id.to_s
218
- name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
219
- return "context #{name} changed" if id.start_with?(ClientId::CONTEXT_PREFIX) && !name.empty?
220
-
221
- task = id.delete_prefix(ClientId::TASK_PREFIX)
222
- return "task #{Tools::TaskRecord.short_id(task)} finished" if id.start_with?(ClientId::TASK_PREFIX) && !task.empty?
218
+ case ClientId.origin(id)
219
+ when "context"
220
+ name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
221
+ return "context #{name} changed" unless name.empty?
222
+ when "task"
223
+ task = id.delete_prefix(ClientId::TASK_PREFIX)
224
+ return "task #{Tools::TaskRecord.short_id(task)} finished" unless task.empty?
225
+ end
223
226
 
224
227
  prefix = id[/\A[^:]*:/]
225
228
  CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
@@ -11,6 +11,7 @@ require_relative "../peer_sources"
11
11
  require_relative "../toolkit"
12
12
  require_relative "../guardrails/gate"
13
13
  require_relative "../memory_bundle/manifest"
14
+ require_relative "../memory_bundle/source"
14
15
  require_relative "../session_setup"
15
16
  require_relative "sandbox"
16
17
  require_relative "recorder"
@@ -69,7 +70,7 @@ module Samagotchi
69
70
  # raises HookFailed after the call (chi would log it and go on);
70
71
  # #lenient collects them in #errors instead.
71
72
  class Bundle
72
- SHIPPED_DIR = File.expand_path("../bundles", __dir__)
73
+ SHIPPED_DIR = MemoryBundle::SourceNormalizer::SHIPPED_DIR
73
74
 
74
75
  class << self
75
76
  # @param dir [String] the bundle's folder (its manifest.yml)
@@ -88,6 +89,20 @@ module Samagotchi
88
89
 
89
90
  # A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
90
91
  def shipped(name, **) = load(File.join(SHIPPED_DIR, name.to_s), **)
92
+
93
+ # Registries as an Engine's start: chi's built-in commands and
94
+ # tools, empty services, toolkits, tool index and peer sources;
95
+ # +fields+ set the rest (hooks:, context_for:, init: ...) or replace
96
+ # these. Bundle#build_registries and Install's load both start here.
97
+ # @api private
98
+ # @return [Plugin::Registries]
99
+ def registries(**fields)
100
+ Plugin::Registries.new(
101
+ commands: SessionCommands.register_builtins(Commands::Registry.new), tools: Tools::Builtins.registry,
102
+ services: Plugin::Services.new, stage_tools: ->(*) {}, toolkits: [], tool_index: Tools::ToolIndex.new,
103
+ peer_sources: PeerSources.new, **fields
104
+ )
105
+ end
91
106
  end
92
107
 
93
108
  # @return [String] the bundle's name (manifest.yml)
@@ -132,6 +147,13 @@ module Samagotchi
132
147
  own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
133
148
  @own_sandbox = sandbox.nil? && Sandbox.current.nil?
134
149
  @sandbox = sandbox || Sandbox.current || Sandbox.enter
150
+ build(host, own)
151
+ rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
152
+ @sandbox.leave if @own_sandbox && @sandbox
153
+ raise
154
+ end
155
+
156
+ def build(host, own)
135
157
  @recorder = Recorder.new(cwd: @sandbox.work_dir, **host, **own)
136
158
  @sessions = Sessions.new
137
159
  @http = FakeHttp.new
@@ -143,6 +165,7 @@ module Samagotchi
143
165
  @contexts = {}
144
166
  @registries = build_registries
145
167
  end
168
+ private :build
146
169
 
147
170
  # @api private (Bundle.load)
148
171
  def load!
@@ -316,7 +339,7 @@ module Samagotchi
316
339
  def model_answers = @recorder.model_answers
317
340
 
318
341
  # Queue ask_user answers ({selected:, freeform:, selected_indices:},
319
- # or a callable given the Question).
342
+ # or a callable given the HookQuestion).
320
343
  def answer(*answers)
321
344
  @recorder.answers.concat(answers)
322
345
  self
@@ -349,28 +372,22 @@ module Samagotchi
349
372
  private
350
373
 
351
374
  def build_registries
352
- builtin_tools = Tools::Builtins.registry
353
375
  toolkits = []
354
376
  host = Host.build(@recorder, toolkits: -> { Toolkit::BUILTIN_KITS.keys + toolkits.map(&:name) },
355
377
  tool_names: -> { @registries.tools.names })
356
- Plugin::Registries.new(
357
- commands: SessionCommands.register_builtins(Commands::Registry.new),
358
- tools: builtin_tools,
378
+ Bundle.registries(
359
379
  hooks: Hooks::Registry.new.tap { |registry| registry.runtime = Runtime.build(@recorder) },
360
380
  context_for: lambda { |bundle, settings, label|
361
381
  @contexts[label] = Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host,
362
382
  env: ENV, sessions: @sessions, http_transport: @http)
363
383
  },
364
384
  tools_changed: -> { @tools_changes += 1 },
365
- services: Plugin::Services.new,
366
385
  stage_tools: ->(_bundle, specs, context) { @staged_tools << StagedTools.new(specs: specs, context: context) },
367
386
  init: lambda { |_bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
368
387
  @inits << Init.new(label: label, plugin_label: plugin_label, provides_tools: provides_tools, quiet: quiet,
369
388
  timeout: timeout, failed: failed, block: block)
370
389
  },
371
- toolkits: toolkits,
372
- tool_index: Tools::ToolIndex.new,
373
- peer_sources: PeerSources.new
390
+ toolkits: toolkits
374
391
  )
375
392
  end
376
393
 
@@ -388,21 +405,17 @@ module Samagotchi
388
405
 
389
406
  # +setup+ (the recorder's, nil for none) with +session_settings+ as
390
407
  # this bundle's own values, checked against the manifest's
391
- # session_settings: as chi --set checks them.
392
- # @raise [ArgumentError] a key the manifest doesn't list
408
+ # session_settings: (keys and types) as chi --set checks them.
409
+ # @raise [ArgumentError] a key the manifest doesn't list, or a value
410
+ # of another type than it gives the key
393
411
  def setup_with(session_settings, setup)
394
412
  values = { @name => stringify(session_settings) }
395
- BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings }, flag: "session_settings")
413
+ BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings },
414
+ types: { @name => @manifest.session_setting_types }, flag: "session_settings")
396
415
  (setup || SessionSetup.new).with(bundle_settings: values)
397
416
  end
398
417
 
399
- def stringify(value)
400
- case value
401
- when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
402
- when Array then value.map { |item| stringify(item) }
403
- else value
404
- end
405
- end
418
+ def stringify(value) = Sandbox.stringify(value)
406
419
  end
407
420
  end
408
421
  end
@@ -27,10 +27,7 @@ module Samagotchi
27
27
  messages: -> { recorder[:messages] },
28
28
  messages_partial: -> { recorder[:messages_partial] },
29
29
  notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
30
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
31
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
32
- effects: effects)
33
- },
30
+ ask_user: ->(question) { recorder.ask_user(question) },
34
31
  cancelled: -> { recorder[:cancelled] },
35
32
  card: ->(**card) { recorder.card(**card) },
36
33
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
@@ -89,10 +86,7 @@ module Samagotchi
89
86
  def build(recorder)
90
87
  Hooks::Runtime.new(
91
88
  notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
92
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
93
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
94
- effects: effects)
95
- },
89
+ ask_user: ->(question) { recorder.ask_user(question) },
96
90
  stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
97
91
  steer: ->(text:, hook:) { recorder.steer(text, hook) },
98
92
  stop_generation: ->(reason:, hook:) { recorder.stop(:generation, reason, hook) }
@@ -19,16 +19,24 @@ module Samagotchi
19
19
  # failed names the bundle's source files, not the installed copies.
20
20
  #
21
21
  # result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
22
- # result.warnings, result.plugin_failures, result.hook_failures, result.registered[:commands]
22
+ # result.warnings, result.plugin_failures, result.hook_failures, result.registered.commands
23
23
  module Install
24
24
  # warnings: the Installer's; plugin_failures / hook_failures:
25
25
  # [Guardrails::LoadFailures::Failure] of this bundle, each location
26
26
  # in the source dir; registered: what it registered (#registered).
27
27
  Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
28
28
  def failures = plugin_failures + hook_failures
29
- def ok? = failures.empty?
30
29
  end
31
30
 
31
+ # What a bundle registered (#registered): commands, tools (names),
32
+ # hooks per event, inits (labels), toolkits, tool sources and peer
33
+ # sources (names).
34
+ Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources)
35
+ # A command: its mid_turn policy and its selection-bar labels (chi.annotation).
36
+ Command = Data.define(:name, :mid_turn, :labels)
37
+ # A hook on an event: the bundle, the hook (its file, or the plugin's) and its priority.
38
+ Hook = Data.define(:bundle, :hook, :priority)
39
+
32
40
  module_function
33
41
 
34
42
  # Install the bundle at +dir+ into +sandbox+ (default Sandbox.current,
@@ -57,15 +65,12 @@ module Samagotchi
57
65
  hook_failures = Guardrails::LoadFailures.new
58
66
  plugin_failures = Guardrails::LoadFailures.new
59
67
  extension = ExtensionLoad.new(hook_failures: hook_failures)
60
- registries = Plugin::Registries.new(
61
- commands: SessionCommands.register_builtins(Commands::Registry.new),
62
- tools: Tools::Builtins.registry, hooks: extension.hooks,
68
+ registries = Bundle.registries(
69
+ hooks: extension.hooks,
63
70
  context_for: lambda { |bundle, bundle_settings, label|
64
71
  Plugin::Context.new(bundle: bundle, label: label, settings: bundle_settings, host: host)
65
72
  },
66
- services: Plugin::Services.new, stage_tools: ->(*) {},
67
- init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } },
68
- toolkits: [], tool_index: Tools::ToolIndex.new, peer_sources: PeerSources.new
73
+ init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } }
69
74
  )
70
75
  extension.load_plugins(registries, failures: plugin_failures)
71
76
  installed = File.join(MemoryBundle::Provenance.bundles_dir, name)
@@ -79,26 +84,30 @@ module Samagotchi
79
84
  private_class_method :load_installed
80
85
 
81
86
  # What bundle +name+ registered, from the registries it loaded into:
82
- # commands ([name, mid_turn, annotation labels]), tools, hooks per
83
- # event ([bundle, hook, priority]; Hooks::EVENTS, then any other event
84
- # the bundle registered for), inits, toolkits, tool sources and
85
- # peer sources.
87
+ # hooks per event are Hooks::EVENTS, then any other event the bundle
88
+ # registered for.
86
89
  # @param inits [Array<Hash>] {label:} of each chi.init
87
- # @return [Hash]
90
+ # @return [Registered]
88
91
  def registered(registries, name, inits)
89
- {
90
- commands: registries.commands.entries.select { |e| e.source == name }.map { |e| [e.name, e.mid_turn, e.annotations.map(&:label)] },
92
+ Registered.new(
93
+ commands: registries.commands.entries.select { |e| e.source == name }.map do |e|
94
+ Command.new(name: e.name, mid_turn: e.mid_turn, labels: e.annotations.map(&:label))
95
+ end,
91
96
  tools: registries.tools.by_source(name).map(&:name),
92
97
  hooks: hooks_of(registries.hooks, name),
93
98
  inits: inits.map { |init| init[:label] },
94
99
  toolkits: Array(registries.toolkits).map(&:name),
95
100
  tool_sources: registries.tool_index.sources.map(&:label),
96
101
  peer_sources: registries.peer_sources.names
97
- }
102
+ )
98
103
  end
99
104
 
100
105
  def hooks_of(hooks, name)
101
- own = ->(event) { hooks.bundle_hook_names(event).select { |bundle, _, _| bundle == name } }
106
+ own = lambda do |event|
107
+ hooks.bundle_hook_names(event).filter_map do |bundle, hook, priority|
108
+ Hook.new(bundle: bundle, hook: hook, priority: priority) if bundle == name
109
+ end
110
+ end
102
111
  others = (hooks.bundle_events - Hooks::EVENTS).to_h { |event| [event, own.call(event)] }.reject { |_, list| list.empty? }
103
112
  Hooks::EVENTS.to_h { |event| [event, own.call(event)] }.merge(others)
104
113
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../plugin/card"
4
4
  require_relative "../plugin/context"
5
5
  require_relative "../guardrails/parent_policy"
6
+ require_relative "../hook_question"
6
7
 
7
8
  module Samagotchi
8
9
  module Testing
@@ -13,11 +14,6 @@ module Samagotchi
13
14
  Steer = Data.define(:text, :hook)
14
15
  # A stop_turn or stop_generation (+kind+ :turn or :generation).
15
16
  Stop = Data.define(:kind, :reason, :hook)
16
- # An ask_user question; effects: its options' tags as the bundle gave
17
- # them (label => effect), nil for none.
18
- Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
19
- def initialize(effects: nil, **) = super
20
- end
21
17
  # An ask_model request: the request Context built (SideQuestion), its
22
18
  # limits, cancel controller and the model: it named (nil: the session's;
23
19
  # the kit doesn't check it against any hosts), and +source+, the asking
@@ -107,11 +103,12 @@ module Samagotchi
107
103
  # Effects chi would refuse (a label the options don't have, an
108
104
  # unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
109
105
  # in chi: nil, nothing recorded, no answer taken.
110
- def ask_user(question:, options:, header:, allow_freeform:, hook:, effects: nil)
111
- return nil if Guardrails::ParentPolicy.option_tags(options, effects) == false
106
+ # @param question [HookQuestion] recorded as asked (#questions; its
107
+ # effects as the bundle gave them: label => effect, nil for none)
108
+ def ask_user(question)
109
+ return nil if Guardrails::ParentPolicy.option_tags(question.options, question.effects) == false
112
110
 
113
- record(@questions, Question.new(question: question, options: options, header: header,
114
- allow_freeform: allow_freeform, hook: hook, effects: effects))
111
+ record(@questions, question)
115
112
  answer = @mutex.synchronize { @answers.shift }
116
113
  answer.respond_to?(:call) ? answer.call(@questions.last) : answer
117
114
  end
@@ -5,6 +5,7 @@ require "tmpdir"
5
5
  require "yaml"
6
6
  require_relative "../log"
7
7
  require_relative "../paths"
8
+ require_relative "../sandbox_layout"
8
9
  require_relative "fake_executable"
9
10
 
10
11
  module Samagotchi
@@ -38,7 +39,16 @@ module Samagotchi
38
39
  sandbox&.leave
39
40
  end
40
41
 
42
+ # +value+ with every Hash key a String, as config.yml and a
43
+ # handler's args read (Bundle uses it too).
41
44
  # @api private
45
+ def stringify(value)
46
+ case value
47
+ when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
48
+ when Array then value.map { |item| stringify(item) }
49
+ else value
50
+ end
51
+ end
42
52
  end
43
53
 
44
54
  # @return [String] the temp folder holding everything
@@ -55,31 +65,16 @@ module Samagotchi
55
65
  def enter
56
66
  raise ArgumentError, "the sandbox was entered already" if @root
57
67
 
58
- @root = File.realpath(Dir.mktmpdir("chi-bundle-test-"))
59
- @saved = ENV.to_h
60
- @outer = Sandbox.current
61
- ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
62
- %w[config state data cache home work bin].each { |dir| FileUtils.mkdir_p(File.join(@root, dir)) }
63
- File.write(File.join(@root, "gitconfig"), "")
64
- ENV.update(
65
- "HOME" => File.join(@root, "home"),
66
- "XDG_CONFIG_HOME" => File.join(@root, "config"),
67
- "XDG_STATE_HOME" => File.join(@root, "state"),
68
- "XDG_DATA_HOME" => File.join(@root, "data"),
69
- "XDG_CACHE_HOME" => File.join(@root, "cache"),
70
- "CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
71
- "CODEX_HOME" => File.join(@root, "home", ".codex"),
72
- "GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
73
- "GIT_CONFIG_NOSYSTEM" => "1",
74
- "SAMAGOTCHI_KEYCHAIN" => "off",
75
- "SAMAGOTCHI_ENV" => "test",
76
- "SAMAGOTCHI_RECAP_ENABLED" => "false",
77
- "SAMAGOTCHI_CACHE_WARMUP" => "off",
78
- "PATH" => [bin_dir, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR)
79
- )
80
- # The log resolves its file from the (now temp) state home.
81
- Log.reset!
82
- Sandbox.current = self
68
+ layout = SandboxLayout.create("chi-bundle-test-", extra: %w[data cache home bin])
69
+ begin
70
+ @root = layout.root
71
+ @saved = ENV.to_h
72
+ @outer = Sandbox.current
73
+ point_env(layout)
74
+ rescue Exception # rubocop:disable Lint/RescueException -- put ENV back, whatever failed
75
+ leave
76
+ raise
77
+ end
83
78
  self
84
79
  end
85
80
 
@@ -87,7 +82,7 @@ module Samagotchi
87
82
  def leave
88
83
  return unless @root
89
84
 
90
- ENV.replace(@saved)
85
+ ENV.replace(@saved) if @saved
91
86
  Log.reset!
92
87
  FileUtils.rm_rf(@root)
93
88
  Sandbox.current = @outer
@@ -97,10 +92,7 @@ module Samagotchi
97
92
  # Write config.yml (what Config.get reads): a Hash or YAML text.
98
93
  # @return [String] its path
99
94
  def config(data)
100
- path = File.join(Paths.config_home, Paths::APP_DIR, "config.yml")
101
- FileUtils.mkdir_p(File.dirname(path))
102
- File.write(path, data.is_a?(String) ? data : YAML.dump(stringify(data)))
103
- path
95
+ SandboxLayout.new(@root).write_config(data.is_a?(String) ? data : stringify(data), config_home: Paths.config_home)
104
96
  end
105
97
 
106
98
  # @return [String] $XDG_STATE_HOME/samagotchi (ctx.data_dir's parent)
@@ -145,6 +137,28 @@ module Samagotchi
145
137
 
146
138
  private
147
139
 
140
+ # ENV at +layout+'s folders, none of the SAMAGOTCHI_* settings but KEPT.
141
+ def point_env(layout)
142
+ ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
143
+ File.write(File.join(@root, "gitconfig"), "")
144
+ ENV.update(
145
+ **layout.env,
146
+ "HOME" => File.join(@root, "home"),
147
+ "XDG_DATA_HOME" => File.join(@root, "data"),
148
+ "XDG_CACHE_HOME" => File.join(@root, "cache"),
149
+ "CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
150
+ "CODEX_HOME" => File.join(@root, "home", ".codex"),
151
+ "GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
152
+ "GIT_CONFIG_NOSYSTEM" => "1",
153
+ "SAMAGOTCHI_KEYCHAIN" => "off",
154
+ "SAMAGOTCHI_ENV" => "test",
155
+ "PATH" => [bin_dir, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR)
156
+ )
157
+ # The log resolves its file from the (now temp) state home.
158
+ Log.reset!
159
+ Sandbox.current = self
160
+ end
161
+
148
162
  # Run the block with +dir+ as the process's cwd (the memory tools
149
163
  # read the project from it); nil: as it is.
150
164
  def in_dir(dir, &)
@@ -153,13 +167,7 @@ module Samagotchi
153
167
  Dir.chdir(dir, &)
154
168
  end
155
169
 
156
- def stringify(value)
157
- case value
158
- when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
159
- when Array then value.map { |item| stringify(item) }
160
- else value
161
- end
162
- end
170
+ def stringify(value) = Sandbox.stringify(value)
163
171
  end
164
172
  end
165
173
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../process_group"
4
+
3
5
  module Samagotchi
4
6
  module Testing
5
7
  # Waiting with a deadline, for a test that waits on a thread or a
@@ -24,13 +26,9 @@ module Samagotchi
24
26
  end
25
27
 
26
28
  # Whether no process +pid+ is left (a killed grandchild is reaped a
27
- # moment later: wait_until { process_gone?(pid) }).
28
- def process_gone?(pid)
29
- Process.kill(0, pid)
30
- false
31
- rescue Errno::ESRCH
32
- true
33
- end
29
+ # moment later: wait_until { process_gone?(pid) }). One that isn't
30
+ # ours to signal is still there (ProcessGroup.alive?).
31
+ def process_gone?(pid) = !ProcessGroup.alive?(pid)
34
32
  end
35
33
  end
36
34
  end