samagotchi 0.78.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +11 -6
  6. data/docs/internals/anthropic.md +12 -5
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +15 -5
  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 +8 -4
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
  29. data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +21 -12
  31. data/lib/samagotchi/llm/http.rb +12 -7
  32. data/lib/samagotchi/llm/model_listing.rb +29 -1
  33. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  34. data/lib/samagotchi/plugin/card.rb +44 -0
  35. data/lib/samagotchi/plugin/context.rb +53 -8
  36. data/lib/samagotchi/plugin/http.rb +154 -0
  37. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  38. data/lib/samagotchi/plugin/loader.rb +29 -10
  39. data/lib/samagotchi/question_answer.rb +29 -0
  40. data/lib/samagotchi/question_desk.rb +30 -22
  41. data/lib/samagotchi/reasoning_history.rb +2 -1
  42. data/lib/samagotchi/relay_verifier.rb +1 -1
  43. data/lib/samagotchi/testing/boundary.rb +156 -0
  44. data/lib/samagotchi/testing/bundle.rb +349 -0
  45. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  46. data/lib/samagotchi/testing/fake_http.rb +137 -0
  47. data/lib/samagotchi/testing/helpers.rb +56 -0
  48. data/lib/samagotchi/testing/host.rb +76 -0
  49. data/lib/samagotchi/testing/minitest.rb +31 -0
  50. data/lib/samagotchi/testing/recorder.rb +138 -0
  51. data/lib/samagotchi/testing/recording_api.rb +27 -0
  52. data/lib/samagotchi/testing/rspec.rb +31 -0
  53. data/lib/samagotchi/testing/sandbox.rb +163 -0
  54. data/lib/samagotchi/testing/sessions.rb +77 -0
  55. data/lib/samagotchi/testing/verdict.rb +28 -0
  56. data/lib/samagotchi/testing/waiting.rb +36 -0
  57. data/lib/samagotchi/testing.rb +26 -0
  58. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  59. data/lib/samagotchi/tools/tool_index.rb +3 -0
  60. data/lib/samagotchi/turn_state.rb +28 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/public/app.js +79 -245
  63. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  64. data/lib/samagotchi/web/public/images.js +33 -0
  65. data/lib/samagotchi/web/public/index.html +73 -40
  66. data/lib/samagotchi/web/public/level_chip.js +11 -11
  67. data/lib/samagotchi/web/public/lists_view.js +19 -14
  68. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  69. data/lib/samagotchi/web/public/model_picker.js +5 -6
  70. data/lib/samagotchi/web/public/scope.js +3 -3
  71. data/lib/samagotchi/web/public/stage_view.js +3 -2
  72. data/lib/samagotchi/web/public/storage.js +23 -0
  73. data/lib/samagotchi/web/public/strip.js +35 -9
  74. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  75. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  76. data/lib/samagotchi/web/public/toasts.js +5 -4
  77. metadata +24 -1
@@ -65,22 +65,41 @@ module Samagotchi
65
65
  reason = bundle.error || unloadable_reason(file, bundle)
66
66
  return failed(bundle_name, basename, reason, failures) if reason
67
67
 
68
- api = nil
69
- plugin = instantiate(bundle_name, file, settings)
70
- plugin_label = label(bundle_name, basename)
71
- api = Api.new(bundle: bundle_name, label: plugin_label, registries: registries,
72
- context: registries.context_for&.call(bundle_name, settings, plugin_label))
73
- plugin.register(api)
74
- api.commit!
68
+ api = register_file(bundle_name, file, registries, settings: settings)
75
69
  Log.info(TAG, "plugin_loaded", bundle: bundle_name, file: basename, **api.counts)
76
70
  true
77
71
  rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
78
- api&.abort!
79
72
  raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
80
73
 
81
74
  failed(bundle_name, basename, "#{e.class}: #{e.message}", failures)
82
75
  end
83
76
 
77
+ # Build +file+'s plugin with +settings+ and commit what its #register
78
+ # staged into +registries+ (no sha or requires_chi check: #load_bundle
79
+ # makes them; the test kit loads a bundle's source this way).
80
+ # @param namespace [Module, nil] the module the file is evaluated in
81
+ # (default: a fresh one, #fresh_namespace)
82
+ # @param api_class [Class] what #register gets (Api; the test kit's
83
+ # recording subclass)
84
+ # @yieldparam plugin [Object] the built plugin, before #register
85
+ # @return [Api] the committed Api (#counts)
86
+ # @raise [Exception] whatever the file, #register or #commit! raised,
87
+ # after stopping the services it started; nothing is registered
88
+ def register_file(bundle_name, file, registries, settings: {}, namespace: nil, api_class: Api)
89
+ api = nil
90
+ plugin = instantiate(bundle_name, file, settings, namespace: namespace)
91
+ yield plugin if block_given?
92
+ plugin_label = label(bundle_name, File.basename(file))
93
+ api = api_class.new(bundle: bundle_name, label: plugin_label, registries: registries,
94
+ context: registries.context_for&.call(bundle_name, settings, plugin_label))
95
+ plugin.register(api)
96
+ api.commit!
97
+ api
98
+ rescue Exception # rubocop:disable Lint/RescueException -- stop its services whatever it raised
99
+ api&.abort!
100
+ raise
101
+ end
102
+
84
103
  # What a plugin's hooks, notices and failures are named by.
85
104
  def label(bundle_name, basename) = "#{basename} (bundle #{bundle_name})"
86
105
 
@@ -98,8 +117,8 @@ module Samagotchi
98
117
  # classes, not the last load's reopened) and build its class
99
118
  # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are built:
100
119
  # an initialize that takes an argument gets the settings.
101
- def instantiate(bundle_name, file, settings)
102
- namespace = fresh_namespace(bundle_name)
120
+ def instantiate(bundle_name, file, settings, namespace: nil)
121
+ namespace ||= fresh_namespace(bundle_name)
103
122
  namespace.module_eval(File.read(file), file, 1)
104
123
  klass = Hooks.class_for(file, namespace)
105
124
  plugin = Hooks.build_plugin(klass, settings)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # An answer to the pending question (QuestionDesk#answer's value, what
5
+ # Engine#answer_question returns): id: the question's id; selected: the
6
+ # chosen option labels; freeform: the typed text or nil; selected_indices:
7
+ # the chosen options' positions; by: "parent_agent" for a parent agent's
8
+ # answer, else nil.
9
+ #
10
+ # Crosses a process or a bundle as a Hash (#to_h, symbol keys): the
11
+ # :question_answered event, the Bridge's and RelayVerifier's JSON
12
+ # responses, the ask_user_question tool result and the hook/plugin
13
+ # ask_user result (built field by field in QuestionDesk#ask_for_hook).
14
+ # JSON.generate of a Data value writes its inspect string, so every JSON
15
+ # boundary calls #to_h first.
16
+ QuestionAnswer = Data.define(:id, :selected, :freeform, :selected_indices, :by) do
17
+ def initialize(id:, selected:, freeform: nil, selected_indices: [], by: nil)
18
+ super
19
+ end
20
+
21
+ # Whether a parent agent gave it (not the user).
22
+ def parent_agent? = by == "parent_agent"
23
+
24
+ # The event / JSON shape: symbol keys in member order; +by+ left out
25
+ # when nil, every other key kept even when nil.
26
+ # @return [Hash{Symbol => Object}]
27
+ def to_h = super.tap { |h| h.delete(:by) if h[:by].nil? }
28
+ end
29
+ end
@@ -8,6 +8,7 @@ require "time"
8
8
  require_relative "client_id"
9
9
  require_relative "log"
10
10
  require_relative "pending_question"
11
+ require_relative "question_answer"
11
12
  require_relative "tools/ask_user_question"
12
13
  require_relative "guardrails/parent_approvals"
13
14
  require_relative "guardrails/parent_continue"
@@ -124,7 +125,7 @@ module Samagotchi
124
125
  result = { dismissed: true, id: result[:id], note: note }
125
126
  end
126
127
  # Who answered is for the relay, not the model's tool result.
127
- result = result.except(:by) if result.is_a?(Hash)
128
+ result = result.to_h.except(:by) if result.is_a?(QuestionAnswer)
128
129
  result.is_a?(String) ? result : JSON.generate(result)
129
130
  end
130
131
 
@@ -139,8 +140,8 @@ module Samagotchi
139
140
  # question waits (outside the lock): a String closes the question with
140
141
  # that reason, announced and returned as a cancel; nil keeps waiting.
141
142
  # An answer recorded first wins. Not on the sync-handler path.
142
- # @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
143
- # or {error:, …}; a String when a sync handler returned text itself
143
+ # @return [QuestionAnswer, Hash, String] the answer, or {error:, …}; a
144
+ # String when a sync handler returned text itself
144
145
  def open_question(fields, watch: nil)
145
146
  supersede_standing
146
147
  ask(fields, watch)
@@ -156,7 +157,7 @@ module Samagotchi
156
157
  # close (as a superseded one does). A standing one already up is
157
158
  # replaced (withdrawn, reason "replaced").
158
159
  # @param fields [Hash] as for #open_question
159
- # @param on_answer [#call] (answer, client_id:)
160
+ # @param on_answer [#call] (answer [QuestionAnswer], client_id:)
160
161
  # @param on_superseded_close [#call, nil] called when a question that
161
162
  # superseded it closes (its poster posts it again if it still
162
163
  # stands); nil: posted again as it was
@@ -220,14 +221,16 @@ module Samagotchi
220
221
  @answer
221
222
  elsif sync_res.is_a?(Hash) && sync_res[:selected]
222
223
  # Treat returned hash as answer (handler rendered and parsed)
223
- @answer = sync_res
224
+ @answer = QuestionAnswer.new(id: id.to_s, selected: Array(sync_res[:selected]),
225
+ freeform: sync_res[:freeform],
226
+ selected_indices: Array(sync_res[:selected_indices]))
224
227
  @pending = nil
225
- sync_res
228
+ @answer
226
229
  end
227
230
  end
228
231
  if ans
229
232
  clear_saved_question
230
- emit({ type: :question_answered, id: id, answer: ans })
233
+ emit({ type: :question_answered, id: id, answer: ans.to_h })
231
234
  return ans
232
235
  end
233
236
  if sync_res.is_a?(String) && !sync_res.strip.empty?
@@ -274,7 +277,7 @@ module Samagotchi
274
277
  end
275
278
 
276
279
  if answer
277
- emit({ type: :question_answered, id: id, answer: answer })
280
+ emit({ type: :question_answered, id: id, answer: answer.to_h })
278
281
  answer
279
282
  else
280
283
  { error: "no answer", id: id }
@@ -289,7 +292,7 @@ module Samagotchi
289
292
  # @param parent_agent [Boolean, nil] the answer is a parent agent's, so
290
293
  # it is held to guardrails.parent_approvals on an approval and doesn't
291
294
  # bring the session back to the lists; nil: chi answer's client id says so
292
- # @return [Hash] normalized answer; a parent agent's carries by: "parent_agent"
295
+ # @return [QuestionAnswer] the answer; a parent agent's carries by: "parent_agent"
293
296
  # @raise [NotPending, ArgumentError, Refused]
294
297
  def answer(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
295
298
  sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
@@ -349,7 +352,7 @@ module Samagotchi
349
352
  @pending = pending = pending.with(relayed_to: relayed_to)
350
353
  if session
351
354
  session.pending_question = pending
352
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
355
+ save_session(session)
353
356
  end
354
357
  pending.id
355
358
  end
@@ -398,7 +401,8 @@ module Samagotchi
398
401
  # web), single-select, kind "hook". A --non-interactive run has no one
399
402
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
400
403
  # answer, cancelled) is nil too.
401
- # @return [Hash, nil] {selected:, freeform:, selected_indices:}
404
+ # @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
405
+ # Hash: the bundle boundary)
402
406
  def ask_for_hook(question, options, header, allow_freeform, hook)
403
407
  return nil if @interface_lookup.call == :non_interactive
404
408
 
@@ -411,11 +415,9 @@ module Samagotchi
411
415
  fields = { question: question.to_s, options: opts, header: header, multi_select: false,
412
416
  allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
413
417
  answer = open_question(fields)
414
- return nil unless answer.is_a?(Hash) && answer[:selected]
418
+ return nil unless answer.is_a?(QuestionAnswer)
415
419
 
416
- result = { selected: Array(answer[:selected]), freeform: answer[:freeform] }
417
- result[:selected_indices] = answer[:selected_indices] if answer.key?(:selected_indices)
418
- result
420
+ { selected: answer.selected, freeform: answer.freeform, selected_indices: answer.selected_indices }
419
421
  end
420
422
 
421
423
  private
@@ -439,7 +441,7 @@ module Samagotchi
439
441
  end
440
442
  if session
441
443
  session.pending_question = pending
442
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
444
+ save_session(session)
443
445
  end
444
446
  event = { type: :question_requested, pending_question: pending.to_h }
445
447
  event[:standing] = true if standing
@@ -483,7 +485,7 @@ module Samagotchi
483
485
  # handed to its poster with who answered.
484
486
  def settle_standing(standing, answer, client_id)
485
487
  clear_saved_question
486
- emit({ type: :question_answered, id: answer[:id], answer: answer })
488
+ emit({ type: :question_answered, id: answer.id, answer: answer.to_h })
487
489
  standing[:on_answer].call(answer, client_id: client_id)
488
490
  rescue StandardError => e
489
491
  Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
@@ -493,7 +495,7 @@ module Samagotchi
493
495
  # == label in v1), one for a single-select, one or a text when required,
494
496
  # and a parent agent's within guardrails.parent_approvals. Under the
495
497
  # question lock: checked against the question pending now.
496
- # @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
498
+ # @return [QuestionAnswer]
497
499
  # @raise [ArgumentError, Refused]
498
500
  def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
499
501
  opts = Array(pending.options)
@@ -508,9 +510,8 @@ module Samagotchi
508
510
  reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
509
511
  raise Refused, reason if reason
510
512
  end
511
- answer = { id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
512
- answer[:by] = "parent_agent" if parent_agent
513
- answer
513
+ QuestionAnswer.new(id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact,
514
+ by: parent_agent ? "parent_agent" : nil)
514
515
  end
515
516
 
516
517
  # Wait until the question is answered, cancelled or closed by +watch+.
@@ -559,12 +560,19 @@ module Samagotchi
559
560
  def cancel_controller = @cancel_controller_lookup.call
560
561
  def emit(event) = @emit.call(event)
561
562
 
563
+ def save_session(session)
564
+ session.save(state_dir: state_dir)
565
+ rescue StandardError => e
566
+ Log.warn(:turn, "question_save_failed", sid: session.id, error: e.class.name, msg: e.message.to_s[0, 200])
567
+ nil
568
+ end
569
+
562
570
  # The session file no longer holds a pending question.
563
571
  def clear_saved_question
564
572
  return unless session
565
573
 
566
574
  session.pending_question = nil
567
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
575
+ save_session(session)
568
576
  end
569
577
  end
570
578
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "setting_explained"
5
+ require_relative "llm/anthropic_wire"
5
6
 
6
7
  module Samagotchi
7
8
  # Which of its own reasoning a chat host's model gets back
@@ -125,7 +126,7 @@ module Samagotchi
125
126
  next false unless entry[:role].to_s == "model"
126
127
 
127
128
  (entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?) ||
128
- Array(entry[:anthropic_content]).any? { |block| block.is_a?(Hash) && %w[thinking redacted_thinking].include?(block["type"].to_s) }
129
+ Array(entry[:anthropic_content]).any? { |block| LLM::AnthropicWire.thinking_block?(block) }
129
130
  end
130
131
  end
131
132
 
@@ -54,7 +54,7 @@ module Samagotchi
54
54
  result = @engine.answer_question(id: question_id, selected: selected, freeform: answer["freeform"],
55
55
  client_id: client_id, parent_agent: parent_agent)
56
56
  log("relay_answered", relay_id, id: question_id, by: relay["by"])
57
- [{}, 200, { status: "answered", question_id: question_id, answer: result }]
57
+ [{}, 200, { status: "answered", question_id: question_id, answer: result.to_h }]
58
58
  rescue QuestionDesk::NotPending
59
59
  not_pending(question_id)
60
60
  rescue QuestionDesk::Refused => e
@@ -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