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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context_usage"
4
+
5
+ module Samagotchi
6
+ class Bridge
7
+ # What an SSE client gets for an engine event (SSEWriter#write_event, live
8
+ # and replayed alike). A :generation_chunk carries chi's own counts
9
+ # (`counts`, ContextUsage.normalize of the provider's payload) instead of
10
+ # the provider's raw `payload`, and no raw `content` (the chat loop's is
11
+ # reasoning + content again, the native loop's the raw stream with its
12
+ # tool-call bytes and thought tags): clients read `text`, `thinking`,
13
+ # `tool_call` and `counts`, never a provider's payload shape. `?raw=1` on
14
+ # the worker's stream skips this (debugging).
15
+ #
16
+ # Pure: the event is the one hash SessionObserver hands every observer
17
+ # (the ring, TurnAccumulator, the other writers, SessionMetrics, hooks),
18
+ # so it is never mutated; keys are added or removed on a copy, and any
19
+ # key not named here (event_seq included) passes through.
20
+ module ClientEvent
21
+ # The counts a client gets: ctx_pct stays out, as normalize computes it
22
+ # without the session's LLM context budget (the client counts
23
+ # total_tokens against the window or budget itself).
24
+ COUNT_KEYS = %i[prompt_tokens completion_tokens total_tokens context_window_tokens].freeze
25
+
26
+ module_function
27
+
28
+ # @param event [Hash] the engine's event (symbol keys; its payload has
29
+ # string keys)
30
+ # @return [Hash] the event as clients get it: a copy for a
31
+ # :generation_chunk, the same object for every other event
32
+ def project(event)
33
+ return event unless event.is_a?(Hash) && event[:type].to_s == "generation_chunk"
34
+
35
+ projected = event.except(:payload, :content)
36
+ found = counts(event[:payload])
37
+ found ? projected.merge(counts: found) : projected
38
+ end
39
+
40
+ # The payload's normalized counts, nil keys left out; nil when the
41
+ # payload reports none (a missing key means unknown, never 0).
42
+ # @return [Hash, nil]
43
+ def counts(payload)
44
+ usage = ContextUsage.normalize(payload)
45
+ found = usage&.slice(*COUNT_KEYS)&.compact
46
+ found.nil? || found.empty? ? nil : found
47
+ end
48
+ end
49
+ end
50
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "client_event"
5
6
  require_relative "event_id"
6
7
 
7
8
  module Samagotchi
@@ -58,10 +59,12 @@ module Samagotchi
58
59
  # @param bridge [Samagotchi::Bridge, nil] for stop signalling
59
60
  # @param max_queue [Integer] bounded-queue capacity
60
61
  # @param heartbeat_interval [Float] idle-seconds between `: ping` frames
62
+ # @param raw [Boolean] write events as the engine emitted them, not as
63
+ # clients get them (ClientEvent): `?raw=1`, for debugging
61
64
  def initialize(engine:, ring:, session_id:, last_event_id: nil, epoch: nil,
62
65
  frame_provider: nil, join_with_snapshot: false, bridge: nil,
63
66
  max_queue: DEFAULT_MAX_QUEUE,
64
- heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
67
+ heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, raw: false)
65
68
  @engine = engine
66
69
  @ring = ring
67
70
  @session_id = session_id
@@ -72,6 +75,7 @@ module Samagotchi
72
75
  @bridge = bridge
73
76
  @max_queue = max_queue
74
77
  @heartbeat_interval = heartbeat_interval
78
+ @raw = raw
75
79
 
76
80
  @queue = BoundedQueue.new(capacity: @max_queue)
77
81
  cursor_seq, cursor_epoch = EventId.parse(@last_event_id)
@@ -203,7 +207,7 @@ module Samagotchi
203
207
  seq = event[:event_seq]
204
208
  return if seq && seq <= @high_water
205
209
 
206
- write_frame(io, seq: seq, data: event)
210
+ write_frame(io, seq: seq, data: @raw ? event : ClientEvent.project(event))
207
211
  @high_water = seq if seq && seq > @high_water
208
212
  end
209
213
 
@@ -19,7 +19,6 @@ require_relative "bridge/sse_writer"
19
19
  require_relative "bridge/turn_accumulator"
20
20
  require_relative "session"
21
21
  require_relative "engine"
22
- require_relative "guardrails/parent_approvals"
23
22
  require_relative "session_commands"
24
23
  require_relative "queued_command"
25
24
  require_relative "steer"
@@ -309,10 +308,11 @@ module Samagotchi
309
308
  # @param after_file [String, nil] the input file it came in (its
310
309
  # basename): it runs once that file is taken (Worker#on_command)
311
310
  # @return [String] its command_id
312
- def queue_command(line, client_id: nil, card: false, after_file: nil)
311
+ def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil)
313
312
  # A card's action (the step-limit question's answer): the UIs leave
314
- # its line out.
315
- command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card)
313
+ # its line out. +enqueued_id+: a file-delivered command's input, whose
314
+ # sender's wait its command_ran settles.
315
+ command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card).with(enqueued_id: enqueued_id)
316
316
  @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
317
317
  command.command_id
318
318
  end
@@ -420,7 +420,7 @@ module Samagotchi
420
420
  cursor = reconnect_cursor(headers, request[:query])
421
421
  Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
422
422
  serve_sse(io, route.last, last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
423
- client_id: stream_client_id(request[:query]))
423
+ client_id: stream_client_id(request[:query]), raw: raw_requested?(request[:query]))
424
424
  break # SSE owns the connection until the client disconnects.
425
425
  else
426
426
  payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
@@ -447,7 +447,7 @@ module Samagotchi
447
447
 
448
448
  # Serve an SSE stream. Owns the connection until the client disconnects.
449
449
  # The connection thread IS the writer thread: serve! blocks until then.
450
- def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil)
450
+ def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil, raw: false)
451
451
  unless own_session?(session_id)
452
452
  write_json(io, 404, {}, { error: "unknown_session" })
453
453
  return
@@ -463,7 +463,8 @@ module Samagotchi
463
463
  # A reconnect (with a cursor) replays; only a fresh join snapshots.
464
464
  join_with_snapshot: snapshot && last_event_id.nil?,
465
465
  bridge: self,
466
- heartbeat_interval: @heartbeat_interval
466
+ heartbeat_interval: @heartbeat_interval,
467
+ raw: raw
467
468
  )
468
469
  @mutex.synchronize do
469
470
  @open_streams += 1
@@ -649,7 +650,7 @@ module Samagotchi
649
650
  selected = fetched(parsed, "selected")
650
651
  freeform = fetched(parsed, "freeform")
651
652
  # chi answer's marker: a parent agent, held to this worker's
652
- # guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
653
+ # Guardrails::ParentPolicy (QuestionDesk::Refused). Not a boundary:
653
654
  # any local process may post here, with or without it.
654
655
  client_id = fetched(parsed, "client_id")
655
656
  if qid.to_s.strip.empty?
@@ -667,9 +668,8 @@ module Samagotchi
667
668
  # Another client answered first, or the question was cancelled.
668
669
  [{}, 409, { error: "question_not_pending", detail: e.message }]
669
670
  rescue QuestionDesk::Refused => e
670
- Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
671
- [{}, 403, { error: e.reason == :stop_only ? "parent_continue_refused" : "parent_approval_refused",
672
- detail: Guardrails::ParentApprovals.message(e.reason) }]
671
+ Log.info(:bridge, "parent_answer_refused", sid: session_id, id: qid, reason: e.reason)
672
+ [{}, 403, { error: "parent_answer_refused", reason: e.reason.to_s, detail: e.detail }]
673
673
  rescue ArgumentError => e
674
674
  [{}, 400, { error: "invalid_answer", detail: e.message }]
675
675
  end
@@ -1180,6 +1180,17 @@ module Samagotchi
1180
1180
  false
1181
1181
  end
1182
1182
 
1183
+ # `?raw=1`: events as the engine emitted them (a chunk's provider
1184
+ # payload), not as clients get them (ClientEvent). For debugging on the
1185
+ # worker's port: chi web never passes it on.
1186
+ def raw_requested?(query)
1187
+ return false unless query
1188
+
1189
+ URI.decode_www_form(query).to_h["raw"].to_s == "1"
1190
+ rescue StandardError
1191
+ false
1192
+ end
1193
+
1183
1194
  # `?client_id=`: whose stream it is (see #open_streams_except).
1184
1195
  def stream_client_id(query)
1185
1196
  return nil unless query
@@ -114,8 +114,8 @@ module Samagotchi
114
114
  # POST /session/:id/answer. 200 = recorded, 409 = another client answered
115
115
  # first (or the question is gone), 400 = invalid selection, 408
116
116
  # deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
117
- # parent_approval_refused = a parent's allow (client_id cli:answer)
118
- # beyond the worker's guardrails.parent_approvals.
117
+ # parent_answer_refused (with its reason) = a parent's pick (client_id
118
+ # cli:answer) beyond the worker's guardrails.parent_answers.
119
119
  # @param client_id [String, nil] who answers (chi answer marks its own)
120
120
  # @return [Response]
121
121
  def answer(id:, selected:, freeform: nil, client_id: nil)
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "stringio"
5
+ require "tmpdir"
6
+ require_relative "../memory_bundle"
7
+ require_relative "../memory_bundle/shas"
8
+ require_relative "../memory_bundle/memory_files"
9
+ require_relative "../plugin/api"
10
+ require_relative "../version"
11
+
12
+ module Samagotchi
13
+ class BundleCommand
14
+ # `chi bundle check [PATH] [--fix] [--set KEY=VALUE]`: what keeps the
15
+ # bundle at PATH from installing and loading cleanly, one finding per
16
+ # line (docs/cli.md):
17
+ #
18
+ # error|warn|info CODE file[:line]: message (fix: hint)
19
+ #
20
+ # It installs and loads the bundle in a Testing::Sandbox (temp HOME and
21
+ # XDG dirs), not in the user's config or state, but in this process: the
22
+ # plugin's and hooks' own code runs as written; --fix rewrites stale
23
+ # sha256 lines (MemoryBundle::Shas). #run returns 0 without errors, 1
24
+ # with any.
25
+ class Check
26
+ Finding = Data.define(:level, :code, :file, :line, :message, :fix) do
27
+ def error? = level == :error
28
+
29
+ def to_s
30
+ where = line ? "#{file}:#{line}" : file
31
+ "#{level} #{code} #{where}: #{message}#{" (fix: #{fix})" if fix}"
32
+ end
33
+ end
34
+
35
+ LIB_DIR = File.expand_path("../..", __dir__)
36
+ TRUSTED = %w[reviewed trusted].freeze
37
+ # A call of the plugin API, or a name SINCE lists, in the source.
38
+ API_CALL = /\b(?:chi|ctx)\.[a-z_]+\b|\bfallback_for:|\bSamagotchi::AtomicFile\b/
39
+ # A requirement's lower bound: >= X, > X, ~> X or = X.
40
+ LOWER_BOUND = /\A(?:>=|>|~>|=)?\s*(\d+(?:\.\d+)*)\z/
41
+
42
+ # @param dir [String] the bundle's folder
43
+ # @param fix [Boolean] rewrite stale sha lines
44
+ # @param settings [Hash] config.yml `bundles: <name>:` for the load
45
+ def initialize(dir, fix: false, settings: {}, stdout: $stdout)
46
+ @dir = File.expand_path(dir)
47
+ @fix = fix
48
+ @settings = settings
49
+ @stdout = stdout
50
+ @findings = []
51
+ end
52
+
53
+ # @return [Integer] exit status
54
+ def run
55
+ manifest = read_manifest
56
+ if manifest
57
+ missing = check_files(manifest)
58
+ check_unlisted(manifest)
59
+ check_shas unless missing
60
+ check_requires_chi(manifest)
61
+ check_trust(manifest)
62
+ check_clash(manifest)
63
+ check_install(manifest) unless missing || placeholders?
64
+ check_boundary(manifest)
65
+ check_tests
66
+ end
67
+ errors = @findings.count(&:error?)
68
+ warnings = @findings.count { |f| f.level == :warn }
69
+ @stdout.puts "#{errors.zero? ? "ok" : "failed"}: #{errors} error#{"s" unless errors == 1}, " \
70
+ "#{warnings} warning#{"s" unless warnings == 1} (#{display(@dir)})"
71
+ errors.zero? ? CLI::Exit::OK : CLI::Exit::FAILED
72
+ end
73
+
74
+ private
75
+
76
+ def add(level, code, file, message, line: nil, fix: nil)
77
+ finding = Finding.new(level: level, code: code, file: file, line: line, message: message, fix: fix)
78
+ @findings << finding
79
+ @stdout.puts finding
80
+ end
81
+
82
+ def path(rel) = File.join(@dir, rel)
83
+
84
+ def display(dir) = dir.start_with?("#{Dir.pwd}/") ? dir.delete_prefix("#{Dir.pwd}/") : dir
85
+
86
+ # Through symlinks on both sides: a linked path to chi's own bundle is it.
87
+ def shipped? = File.dirname(File.realpath(@dir)) == File.realpath(MemoryBundle::SourceNormalizer::SHIPPED_DIR)
88
+
89
+ # ── manifest ──────────────────────────────────────────────────────
90
+
91
+ # The manifest, parsed with its placeholder shas taken (a stale sha is
92
+ # check_shas' finding, not a parse error); nil after a `manifest` error.
93
+ def read_manifest
94
+ unless File.file?(path("manifest.yml"))
95
+ add(:error, :manifest, "manifest.yml", "no manifest.yml in #{display(@dir)}", fix: "chi bundle new NAME")
96
+ return nil
97
+ end
98
+
99
+ @text = File.read(path("manifest.yml"))
100
+ Dir.mktmpdir("chi-bundle-check-") do |tmp|
101
+ File.write(File.join(tmp, "manifest.yml"), MemoryBundle::Shas.parseable(@text))
102
+ MemoryBundle::Manifest.read(dir: tmp)
103
+ rescue MemoryBundle::Manifest::ValidationError => e
104
+ add(:error, :manifest, "manifest.yml", e.message.gsub(tmp, display(@dir)))
105
+ nil
106
+ end
107
+ end
108
+
109
+ # The line of manifest.yml naming +file+ (its entry), or nil.
110
+ def manifest_line(file)
111
+ base = File.basename(file)
112
+ index = @text.lines.index { |l| l.match?(/\A\s*(?:file: )?["']?#{Regexp.escape(base)}["']?:?(?:\s|\z)/) }
113
+ index && (index + 1)
114
+ end
115
+
116
+ # ── files ─────────────────────────────────────────────────────────
117
+
118
+ # @return [Boolean] whether a listed file is missing
119
+ def check_files(manifest)
120
+ listed = manifest.files.keys + Array(manifest.plugin && manifest.plugin[:file]) +
121
+ manifest.hooks.keys.map { |h| File.join("hooks", h) } + manifest.scripts.keys.map { |s| File.join("scripts", s) }
122
+ missing = listed.reject { |rel| File.file?(path(rel)) }
123
+ missing.each do |rel|
124
+ add(:error, :"missing-file", "manifest.yml", "lists #{rel}, which isn't in the bundle", line: manifest_line(rel),
125
+ fix: "add the file or drop the entry")
126
+ end
127
+ !missing.empty?
128
+ end
129
+
130
+ # What an install leaves out: a memory files: doesn't list (by
131
+ # MemoryFiles' rules), a plugin, hook or script the manifest doesn't name.
132
+ def check_unlisted(manifest)
133
+ memories = MemoryBundle::MemoryFiles.select(@dir, manifest)
134
+ loose = Dir.glob(path("*.md")).map { |f| File.basename(f) }.sort.reject do |name|
135
+ memories.include?(name) || MemoryBundle::MemoryFiles.repo_meta?(name)
136
+ end
137
+ loose.each { |name| unlisted(name, "files:") }
138
+ plugin = manifest.plugin && manifest.plugin[:file]
139
+ unlisted("plugin.rb", "plugin:") if File.file?(path("plugin.rb")) && plugin != "plugin.rb"
140
+ Dir.glob(path("hooks/*.rb")).sort.each do |file|
141
+ unlisted("hooks/#{File.basename(file)}", "hooks:") unless manifest.hooks.key?(File.basename(file))
142
+ end
143
+ Dir.glob(path("scripts/*")).sort.each do |file|
144
+ unlisted("scripts/#{File.basename(file)}", "scripts:") unless manifest.scripts.key?(File.basename(file))
145
+ end
146
+ end
147
+
148
+ def unlisted(rel, key)
149
+ add(:warn, :unlisted, rel, "manifest.yml's #{key} doesn't list it, so an install leaves it out",
150
+ fix: "list it under #{key}, then chi bundle check --fix")
151
+ end
152
+
153
+ # ── sha256 lines ──────────────────────────────────────────────────
154
+
155
+ def check_shas
156
+ if @fix
157
+ text, changes = MemoryBundle::Shas.refresh(@dir, @text)
158
+ unless changes.empty?
159
+ File.write(path("manifest.yml"), text)
160
+ @text = text
161
+ changes.each { |file, _old, new| @stdout.puts "fixed sha #{file} → sha256:#{new[0, 12]}…" }
162
+ end
163
+ end
164
+ MemoryBundle::Shas.stale(@dir, @text).each do |stale|
165
+ line = manifest_line(stale.file)
166
+ if stale.old.nil?
167
+ add(:error, :sha, "manifest.yml", "#{stale.file} has no sha256 line, so it installs unchecked", line: line,
168
+ fix: "add `sha256: sha256:` under its entry, then chi bundle check --fix")
169
+ else
170
+ what = stale.old.empty? ? "a placeholder sha256" : "a stale sha256"
171
+ add(:error, :sha, "manifest.yml", "#{what} for #{stale.file}", line: line, fix: "chi bundle check --fix")
172
+ end
173
+ end
174
+ rescue MemoryBundle::Shas::Error => e
175
+ add(:error, :sha, "manifest.yml", e.message.sub(%r{\A[^/]+/manifest\.yml:? ?}, ""))
176
+ end
177
+
178
+ # ── requires_chi ──────────────────────────────────────────────────
179
+
180
+ def code?(manifest) = manifest.plugin || manifest.hooks.any? || manifest.scripts.any?
181
+
182
+ def check_requires_chi(manifest)
183
+ line = manifest_line("requires_chi")
184
+ if (failure = MemoryBundle::Manifest.requires_chi_failure(manifest.requires_chi, VERSION))
185
+ @too_new = true
186
+ add(:error, :"requires-chi", "manifest.yml", "this chi can't load it: #{failure}", line: line,
187
+ fix: "chi update, or lower requires_chi")
188
+ elsif manifest.requires_chi.nil? && code?(manifest)
189
+ add(:warn, :"requires-chi", "manifest.yml", "no requires_chi for a bundle with code", fix: "add requires_chi: \">= #{VERSION}\"")
190
+ end
191
+ check_floor(manifest, line) if manifest.plugin && manifest.requires_chi && !@too_new
192
+ end
193
+
194
+ # The plugin calls an API newer than the oldest chi requires_chi admits.
195
+ def check_floor(manifest, line)
196
+ floor = lower_bound(manifest.requires_chi)
197
+ return unless floor && File.file?(path(manifest.plugin[:file]))
198
+
199
+ calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).uniq.select { |call| Plugin::Api::SINCE.key?(call) }
200
+ call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
201
+ version = Plugin::Api::SINCE[call]
202
+ return unless Gem::Version.new(version) > floor
203
+
204
+ add(:warn, :"requires-chi-floor", "manifest.yml",
205
+ "requires_chi #{manifest.requires_chi.inspect} admits chi #{floor}, but #{manifest.plugin[:file]} uses " \
206
+ "#{call}, which needs chi #{version}", line: line,
207
+ fix: "requires_chi: \">= #{version}\"")
208
+ end
209
+
210
+ def lower_bound(requirement)
211
+ requirement.to_s.split(",").filter_map do |part|
212
+ version = part.strip[LOWER_BOUND, 1]
213
+ Gem::Version.new(version) if version
214
+ end.max
215
+ end
216
+
217
+ def check_trust(manifest)
218
+ return if shipped? || !TRUSTED.include?(manifest.trust_level)
219
+
220
+ add(:warn, :trust, "manifest.yml", "trust_level: #{manifest.trust_level} is set by the bundle's author",
221
+ line: manifest_line("trust_level"), fix: "leave it out unless someone reviewed the bundle")
222
+ end
223
+
224
+ # ── install and load ──────────────────────────────────────────────
225
+
226
+ # Before the sandbox: the user's installed bundles and chi's shipped ones.
227
+ def check_clash(manifest)
228
+ name = manifest.name
229
+ shipped = MemoryBundle::Listing.shipped.map(&:name) + [MemoryBundle::SystemBundle::BUNDLE_NAME]
230
+ if shipped.include?(name) && !shipped?
231
+ add(:warn, :clash, "manifest.yml", "chi ships a bundle named #{name}; installing this one replaces chi's",
232
+ line: manifest_line("name"), fix: "rename the bundle")
233
+ end
234
+ # A shipped bundle installed from any chi's copy is that bundle.
235
+ return if shipped?
236
+
237
+ record = MemoryBundle::Provenance.new(name: name).record
238
+ return unless record && File.expand_path(record.source.to_s) != @dir
239
+
240
+ add(:warn, :clash, "manifest.yml", "a bundle named #{name} is installed from #{record.source}",
241
+ line: manifest_line("name"), fix: "rename the bundle, or chi bundle upgrade #{display(@dir)} to replace it")
242
+ rescue StandardError
243
+ nil
244
+ end
245
+
246
+ # An unfilled `sha256: sha256:` isn't YAML: the Installer can't read
247
+ # the manifest until --fix fills it.
248
+ def placeholders?
249
+ return false unless @findings.any? { |f| f.code == :sha && f.message.start_with?("a placeholder") }
250
+
251
+ add(:info, :install, "manifest.yml", "skipped until the placeholder shas are filled", fix: "chi bundle check --fix")
252
+ true
253
+ end
254
+
255
+ def check_install(manifest)
256
+ require_relative "../testing"
257
+ result = nil
258
+ # The log's echoes (the "experimental" line every external bundle
259
+ # gets) are left out: what failed is in the result.
260
+ quietly do
261
+ Testing::Sandbox.with do |sandbox|
262
+ result = Testing::Install.run(@dir, settings: @settings, sandbox: sandbox, force: true)
263
+ end
264
+ end
265
+ install_warnings(result.warnings)
266
+ # The loaders refuse a bundle requires_chi rules out: that's the requires-chi error.
267
+ result.failures.each { |failure| install_failure(failure) unless @too_new && failure.reason.include?("it requires chi") }
268
+ report_registered(manifest, result.registered)
269
+ rescue MemoryBundle::Installer::InstallError, MemoryBundle::Manifest::ValidationError => e
270
+ add(:error, :install, "manifest.yml", e.message)
271
+ rescue StandardError => e
272
+ # Whatever else the install or the load raised: one finding, not a trace.
273
+ add(:error, :install, "manifest.yml", "the sandboxed install failed: #{e.class}: #{e.message}")
274
+ end
275
+
276
+ def quietly
277
+ saved = $stderr
278
+ $stderr = StringIO.new
279
+ yield
280
+ ensure
281
+ $stderr = saved
282
+ end
283
+
284
+ def install_warnings(warnings)
285
+ stale_reported = @findings.any? { |f| f.code == :sha }
286
+ warnings.each do |warning|
287
+ next if stale_reported && warning.start_with?("Checksum mismatch")
288
+ next if @too_new && warning.include?("won't load:")
289
+
290
+ if warning.start_with?("needs ")
291
+ add(:warn, :install, "manifest.yml", warning)
292
+ else
293
+ add(:error, :install, "manifest.yml", warning)
294
+ end
295
+ end
296
+ end
297
+
298
+ def install_failure(failure)
299
+ file, line = failure.location&.match(/\A(.+):(\d+)\z/)&.captures
300
+ file = file ? file.delete_prefix("#{@dir}/") : "manifest.yml"
301
+ add(:error, :install, file, "#{failure.what} failed to load: #{failure.reason}", line: line&.to_i)
302
+ end
303
+
304
+ def report_registered(manifest, registered)
305
+ plugin = manifest.plugin ? manifest.plugin[:file] : "manifest.yml"
306
+ registered[:commands].each { |name, _, _| add(:info, :registered, plugin, "command #{name}") }
307
+ registered[:tools].each { |name| add(:info, :registered, plugin, "tool #{name}") }
308
+ registered[:hooks].each do |event, hooks|
309
+ hooks.each do |_bundle, hook, priority|
310
+ file = manifest.hooks.key?(hook) ? "hooks/#{hook}" : plugin
311
+ next add(:info, :registered, file, "hook #{event} (priority #{priority})") if Hooks::EVENTS.include?(event)
312
+
313
+ add(:warn, :"hook-event", file, "hook on #{event.inspect}, which chi never fires",
314
+ fix: "one of #{Hooks::EVENTS.join(", ")}")
315
+ end
316
+ end
317
+ { inits: "init", toolkits: "toolkit", tool_sources: "tool source", peer_sources: "peer source" }.each do |key, what|
318
+ registered[key].each { |name| add(:info, :registered, plugin, "#{what} #{name}") }
319
+ end
320
+ end
321
+
322
+ # ── what it reaches of chi ────────────────────────────────────────
323
+
324
+ def check_boundary(manifest)
325
+ require_relative "../testing/boundary"
326
+ known = shipped? ? Testing::Boundary::EXCEPTIONS.fetch(manifest.name, {}) : {}
327
+ Testing::Boundary.scan(@dir).reject { |f| known.key?(f.reference) }.each do |finding|
328
+ add(:warn, :boundary, finding.file, "#{finding.reference} is internal to chi and may break on a chi upgrade",
329
+ fix: "use chi.* / ctx, or the names docs/plugins.md lists as stable")
330
+ end
331
+ end
332
+
333
+ # ── tests ─────────────────────────────────────────────────────────
334
+
335
+ def check_tests
336
+ tests = Dir.glob(path("test/**/*_test.rb")).sort.map { |f| f.delete_prefix("#{@dir}/") }
337
+ if tests.any?
338
+ add(:info, :tests, "test/", "#{tests.size} file#{"s" unless tests.size == 1}: run chi bundle test " \
339
+ "(Minitest on #{RbConfig.ruby}, -I #{LIB_DIR})")
340
+ elsif File.directory?(path("spec"))
341
+ add(:info, :tests, "spec/", "chi bundle test (runs rspec with the kit)")
342
+ else
343
+ add(:info, :tests, display(@dir), "no tests", fix: "chi bundle new NAME writes one to copy")
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../memory_bundle/shas"
5
+ require_relative "../version"
6
+
7
+ module Samagotchi
8
+ class BundleCommand
9
+ # `chi bundle new NAME [--dir DIR]`: a bundle to start from, in DIR/NAME:
10
+ # a manifest (block-form plugin: entry, its sha filled, requires_chi at
11
+ # this chi), a plugin with one command and one after_tool_call hook, a
12
+ # Minitest test on the kit that passes as written, and a README. The
13
+ # test is the pattern to copy (docs/bundle-authoring.md).
14
+ class Scaffold
15
+ # A name that is also a slash command (Plugin::Api::COMMAND_NAME).
16
+ NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
17
+
18
+ # @return [String] the bundle's folder
19
+ attr_reader :dir
20
+
21
+ def initialize(name, parent: ".")
22
+ @name = name
23
+ @dir = File.expand_path(name, parent)
24
+ end
25
+
26
+ # Write the files; ArgumentError for a bad name or a folder that's there.
27
+ # @return [Array<String>] the files written, relative to #dir
28
+ def write
29
+ raise ArgumentError, "#{@name.inspect} isn't a bundle name: a-z first, then a-z, 0-9, - or _ (32 at most)" unless @name.match?(NAME)
30
+ raise ArgumentError, "#{@dir} exists already" if File.exist?(@dir)
31
+
32
+ files.each do |rel, text|
33
+ FileUtils.mkdir_p(File.dirname(File.join(@dir, rel)))
34
+ File.write(File.join(@dir, rel), text)
35
+ end
36
+ File.write(File.join(@dir, "manifest.yml"), MemoryBundle::Shas.refresh(@dir).first)
37
+ files.keys
38
+ end
39
+
40
+ private
41
+
42
+ def test_file = "test/#{@name.tr("-", "_")}_test.rb"
43
+ def test_class = "#{@name.split(/[-_]/).map(&:capitalize).join}Test"
44
+
45
+ def files
46
+ { "manifest.yml" => manifest, "plugin.rb" => plugin, test_file => test, "README.md" => readme }
47
+ end
48
+
49
+ def manifest
50
+ <<~YAML
51
+ name: #{@name}
52
+ version: 0.1.0
53
+ scope: system
54
+ description: "What #{@name} adds to chi, in one line"
55
+ requires_chi: ">= #{VERSION}"
56
+ plugin:
57
+ file: plugin.rb
58
+ sha256: sha256:
59
+ YAML
60
+ end
61
+
62
+ def plugin
63
+ <<~RUBY
64
+ # frozen_string_literal: true
65
+
66
+ # #{@name}: one slash command and one hook (docs/bundle-authoring.md in chi's source dir).
67
+ class Plugin
68
+ # settings: config.yml `bundles: #{@name}:` (string keys), read once when the plugin loads.
69
+ def initialize(settings)
70
+ @greeting = settings.fetch("greeting", "hello")
71
+ end
72
+
73
+ def register(chi)
74
+ # /#{@name} [WHO]: the block gets the text after the name and returns what to show.
75
+ chi.command("/#{@name}", "say hello") do |args, _ctx|
76
+ "\#{@greeting}, \#{args.empty? ? "there" : args}"
77
+ end
78
+
79
+ # After each tool call: an execute that exited non-zero gets one notice. Match on event[:result]
80
+ # (the tool's own text, ending in "exit: N"), not event[:output] (what the model reads, maybe cut).
81
+ chi.on(:after_tool_call) do |event, ctx|
82
+ next unless event[:tool] == "execute"
83
+
84
+ code = event[:result].to_s[/^exit: (\\d+)/, 1].to_i
85
+ ctx.notify("the command exited \#{code}", level: :warn) unless code.zero?
86
+ end
87
+ end
88
+ end
89
+ RUBY
90
+ end
91
+
92
+ def test
93
+ <<~RUBY
94
+ # frozen_string_literal: true
95
+
96
+ require "minitest/autorun"
97
+ require "samagotchi/testing/minitest"
98
+
99
+ # `chi bundle test` runs this with chi's own Ruby and lib. Each test gets a sandbox (temp HOME, config,
100
+ # state); load_bundle loads the bundle's source through chi's loaders, and the recorders show what it did.
101
+ class #{test_class} < Samagotchi::Testing::Case
102
+ BUNDLE = File.expand_path("..", __dir__)
103
+
104
+ def test_the_command_greets
105
+ assert_equal "hello, chi", load_bundle(BUNDLE).command("/#{@name} chi")
106
+ assert_equal "hi, there", load_bundle(BUNDLE, settings: { "greeting" => "hi" }).command("/#{@name}")
107
+ end
108
+
109
+ def test_a_failed_execute_gets_a_notice
110
+ bundle = load_bundle(BUNDLE)
111
+ # An execute's after_tool_call as chi fires it (docs/hooks.md, Hook Events).
112
+ bundle.fire(:after_tool_call, tool: "execute", status: "error", iteration: 1, elapsed_ms: 12,
113
+ call: { name: "execute", content: "false" },
114
+ result: "exit: 1 (no output)", output: "[execute]\\nexit: 1 (no output)")
115
+ bundle.fire(:after_tool_call, tool: "execute", status: "ok", iteration: 2, elapsed_ms: 9,
116
+ call: { name: "execute", content: "echo hi" },
117
+ result: "stdout:\\nhi\\n\\nexit: 0", output: "[execute]\\nstdout:\\nhi\\n\\nexit: 0")
118
+
119
+ assert_equal [["the command exited 1", :warn]], bundle.notices.map { |n| [n.text, n.level] }
120
+ end
121
+ end
122
+ RUBY
123
+ end
124
+
125
+ def readme
126
+ <<~MD
127
+ # #{@name}
128
+
129
+ A chi bundle: `/#{@name}`, and a notice when a command fails.
130
+ Check it with `chi bundle check`, test it with `chi bundle test`, install it with `chi bundle install .`
131
+ MD
132
+ end
133
+ end
134
+ end
135
+ end