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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -1
- data/docs/architecture.md +34 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +38 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +63 -35
- data/docs/hooks.md +19 -3
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +35 -24
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge.rb +22 -11
- data/lib/samagotchi/bridge_client.rb +2 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +4 -4
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +5 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_report.rb +87 -14
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +11 -5
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +37 -24
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +14 -5
- data/lib/samagotchi/send_command.rb +34 -8
- data/lib/samagotchi/session_commands.rb +14 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -11
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +12 -9
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +6 -1
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -10
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +11 -2
- 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
|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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, "
|
|
671
|
-
[{}, 403, { error: e.reason
|
|
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
|
-
#
|
|
118
|
-
# beyond the worker's guardrails.
|
|
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
|