samagotchi 0.31.0 → 0.33.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 +70 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +3 -1
- data/docs/context.md +220 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +7 -3
- data/docs/plugins.md +79 -2
- data/docs/releasing.md +3 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +6 -0
- data/lib/samagotchi/child_reports.rb +31 -12
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/context_absorber.rb +180 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +195 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +462 -0
- data/lib/samagotchi/engine.rb +19 -11
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -1
- data/lib/samagotchi/process_group.rb +160 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -4
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +7 -5
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_manager.rb +7 -6
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +36 -19
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
- data/lib/samagotchi/terminal_ui.rb +2 -4
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_declarations.rb +21 -0
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +111 -2
- data/lib/samagotchi/web/public/app.js +57 -28
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +24 -0
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +5 -3
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -3
- data/lib/samagotchi/web/public/turn_view.js +5 -2
- data/lib/samagotchi/worker.rb +154 -6
- metadata +18 -1
|
@@ -33,15 +33,6 @@ module Samagotchi
|
|
|
33
33
|
Lexer.new(text).tokens
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
# lex's tokens, each with the range of +text+ it was read from
|
|
37
|
-
# appended ([:word, "x", specials, 0...1] / [:op, "&&", 2...4]), for a
|
|
38
|
-
# display that slices the original text. A heredoc body's range is
|
|
39
|
-
# the body and its terminator line. lex itself is unchanged by this.
|
|
40
|
-
def self.lex_with_spans(text)
|
|
41
|
-
lexer = Lexer.new(text)
|
|
42
|
-
lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
36
|
# The token stream split at operators into word lists; ( and ) become
|
|
46
37
|
# :open / :close markers.
|
|
47
38
|
def self.simple_commands(tokens)
|
|
@@ -5,9 +5,17 @@ module Samagotchi
|
|
|
5
5
|
# The gate's verdict for one tool call. Voters (hooks now, rules later)
|
|
6
6
|
# call #deny! or #ask!; the strictest vote wins (deny > ask > allow) and
|
|
7
7
|
# a vote never relaxes it, so a deny is sticky. Of two equal votes the
|
|
8
|
-
# first one stays
|
|
8
|
+
# first one stays, except that a protected rule's ask (PROTECTED_RULES)
|
|
9
|
+
# takes over an unprotected ask: its rule id is what a parent's answer
|
|
10
|
+
# and a stored approval are checked against, and the offered scopes
|
|
11
|
+
# narrow to both asks' common ones.
|
|
9
12
|
class Verdict
|
|
10
13
|
STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
|
|
14
|
+
# Rules that ask about chi's own config and hooks: the core
|
|
15
|
+
# ProtectedPaths asks and the guardrails bundle's shell rule; and a
|
|
16
|
+
# command source for attached context (it runs later, ungated). Only
|
|
17
|
+
# the user may allow them (ParentApprovals).
|
|
18
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd].freeze
|
|
11
19
|
SCOPES = %w[once session repo rule].freeze
|
|
12
20
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
|
13
21
|
|
|
@@ -46,13 +54,18 @@ module Samagotchi
|
|
|
46
54
|
# @param scopes [Array<String>, nil] which approval scopes the user may
|
|
47
55
|
# pick (default all of SCOPES)
|
|
48
56
|
def ask!(reason, scopes: nil, rule: nil, source: nil, decided_by: "hook")
|
|
57
|
+
earlier = ask? ? @scopes : nil
|
|
49
58
|
return self unless vote(:ask, reason, rule: rule, source: source, decided_by: decided_by)
|
|
50
59
|
|
|
51
60
|
picked = Array(scopes).map(&:to_s) & SCOPES
|
|
52
|
-
|
|
61
|
+
picked = SCOPES if picked.empty?
|
|
62
|
+
picked &= earlier if earlier
|
|
63
|
+
@scopes = picked.empty? ? %w[once] : picked
|
|
53
64
|
self
|
|
54
65
|
end
|
|
55
66
|
|
|
67
|
+
def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
|
|
68
|
+
|
|
56
69
|
# A before_tool_call hook set the old event[:blocked] flag. Its model
|
|
57
70
|
# text stays "blocked by guardrail: <reason>".
|
|
58
71
|
def legacy_deny!(reason)
|
|
@@ -118,7 +131,7 @@ module Samagotchi
|
|
|
118
131
|
end
|
|
119
132
|
|
|
120
133
|
def vote(decision, reason, decided_by:, rule: nil, source: nil)
|
|
121
|
-
return false unless STRICTNESS.fetch(decision) > STRICTNESS.fetch(@decision)
|
|
134
|
+
return false unless STRICTNESS.fetch(decision) > STRICTNESS.fetch(@decision) || takes_over?(decision, rule)
|
|
122
135
|
|
|
123
136
|
@decision = decision
|
|
124
137
|
@reason = reason.to_s.strip
|
|
@@ -129,6 +142,11 @@ module Samagotchi
|
|
|
129
142
|
@legacy = false
|
|
130
143
|
true
|
|
131
144
|
end
|
|
145
|
+
|
|
146
|
+
# A protected rule's ask over an unprotected one.
|
|
147
|
+
def takes_over?(decision, rule)
|
|
148
|
+
decision == :ask && ask? && Verdict.protected_rule?(rule) && !Verdict.protected_rule?(@rule)
|
|
149
|
+
end
|
|
132
150
|
end
|
|
133
151
|
end
|
|
134
152
|
end
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# (debug dumps) are indented by four spaces and belong to the record above.
|
|
17
17
|
module LogLine
|
|
18
18
|
LEVELS = %w[DEBUG INFO WARN ERROR].freeze
|
|
19
|
-
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model].freeze
|
|
19
|
+
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context].freeze
|
|
20
20
|
INDENT = " "
|
|
21
21
|
|
|
22
22
|
Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require_relative "manifest"
|
|
7
7
|
require_relative "placeholder"
|
|
8
|
+
require_relative "../context_providers"
|
|
8
9
|
require_relative "../memory_paths"
|
|
9
10
|
require_relative "../model_overlay"
|
|
10
11
|
|
|
@@ -152,6 +153,10 @@ module Samagotchi
|
|
|
152
153
|
requires_chi = prov_data && prov_data[:requires_chi]
|
|
153
154
|
needs = prov_data && prov_data[:needs]
|
|
154
155
|
|
|
156
|
+
# ── Scripts and context providers: the installed bundle's ──
|
|
157
|
+
scripts_to_copy = installed_scripts(prov, prov_data)
|
|
158
|
+
context_providers = prov_data ? ContextProviders.parse_list(prov_data[:context_providers]) : []
|
|
159
|
+
|
|
155
160
|
# Determine trust_level for the built bundle
|
|
156
161
|
build_trust_level = @trust_level
|
|
157
162
|
if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
|
|
@@ -185,6 +190,11 @@ module Samagotchi
|
|
|
185
190
|
|
|
186
191
|
FileUtils.cp(plugin_src, File.join(staging, File.basename(plugin_src))) if plugin_src
|
|
187
192
|
|
|
193
|
+
unless scripts_to_copy.empty?
|
|
194
|
+
FileUtils.mkdir_p(File.join(staging, "scripts"))
|
|
195
|
+
scripts_to_copy.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
|
|
196
|
+
end
|
|
197
|
+
|
|
188
198
|
Manifest.write(
|
|
189
199
|
dir: staging,
|
|
190
200
|
name: resolved_name,
|
|
@@ -196,7 +206,9 @@ module Samagotchi
|
|
|
196
206
|
trust_level: build_trust_level,
|
|
197
207
|
plugin: plugin_src && { file: File.basename(plugin_src), sha256: Digest::SHA256.hexdigest(File.binread(plugin_src)) },
|
|
198
208
|
requires_chi: requires_chi,
|
|
199
|
-
needs: needs
|
|
209
|
+
needs: needs,
|
|
210
|
+
scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
211
|
+
context_providers: context_providers
|
|
200
212
|
)
|
|
201
213
|
|
|
202
214
|
case format
|
|
@@ -255,6 +267,15 @@ module Samagotchi
|
|
|
255
267
|
|
|
256
268
|
private
|
|
257
269
|
|
|
270
|
+
# The installed bundle's scripts its record lists, file name => path
|
|
271
|
+
# in its scripts/ (a missing one is left out); {} without a record.
|
|
272
|
+
def installed_scripts(prov, prov_data)
|
|
273
|
+
return {} unless prov && prov_data && prov_data[:scripts].is_a?(Hash)
|
|
274
|
+
|
|
275
|
+
prov_data[:scripts].keys.map(&:to_s).sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
|
|
276
|
+
.select { |_, path| File.file?(path) }
|
|
277
|
+
end
|
|
278
|
+
|
|
258
279
|
# Without an allowlist, a memory another installed bundle owns (its
|
|
259
280
|
# record lists it) isn't the user's to share: left out, one line
|
|
260
281
|
# each. The bundle being built (same name) keeps its own.
|
|
@@ -302,6 +302,8 @@ module Samagotchi
|
|
|
302
302
|
# ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
|
|
303
303
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
304
304
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
305
|
+
# ── Scripts: scripts/<file> into <bundle_dir>/scripts/ ────────────
|
|
306
|
+
scripts_for_provenance = install_scripts(manifest, normalized_dir, provenance)
|
|
305
307
|
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
306
308
|
warn_rules_requires_chi(manifest, incoming_rules)
|
|
307
309
|
|
|
@@ -417,7 +419,9 @@ module Samagotchi
|
|
|
417
419
|
plugin_file: plugin_file_for_provenance,
|
|
418
420
|
requires_chi: manifest.requires_chi,
|
|
419
421
|
needs: manifest.needs,
|
|
420
|
-
conflicts: @conflicts.keys
|
|
422
|
+
conflicts: @conflicts.keys,
|
|
423
|
+
scripts_files: scripts_for_provenance,
|
|
424
|
+
context_providers: manifest.context_providers
|
|
421
425
|
)
|
|
422
426
|
end
|
|
423
427
|
|
|
@@ -615,6 +619,44 @@ module Samagotchi
|
|
|
615
619
|
dest
|
|
616
620
|
end
|
|
617
621
|
|
|
622
|
+
# The manifest's scripts, copied into <bundle_dir>/scripts/ (the
|
|
623
|
+
# folder replaced: a script the new version dropped goes). A declared
|
|
624
|
+
# sha256 that differs warns, as a plugin's does.
|
|
625
|
+
# @return [Hash{String => String}] file name => installed path
|
|
626
|
+
def install_scripts(manifest, source_dir, provenance)
|
|
627
|
+
scripts = manifest&.scripts || {}
|
|
628
|
+
scripts.each_key do |file|
|
|
629
|
+
next if File.file?(File.join(source_dir, "scripts", file))
|
|
630
|
+
|
|
631
|
+
raise InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
|
|
632
|
+
end
|
|
633
|
+
return {} if @dry_run
|
|
634
|
+
|
|
635
|
+
unchanged = scripts.keys.select do |file|
|
|
636
|
+
dest = File.join(provenance.scripts_dir, file)
|
|
637
|
+
File.file?(dest) && FileUtils.identical?(File.join(source_dir, "scripts", file), dest)
|
|
638
|
+
end
|
|
639
|
+
FileUtils.rm_rf(provenance.scripts_dir)
|
|
640
|
+
return {} if scripts.empty?
|
|
641
|
+
|
|
642
|
+
FileUtils.mkdir_p(provenance.scripts_dir)
|
|
643
|
+
scripts.to_h do |file, declared|
|
|
644
|
+
dest = File.join(provenance.scripts_dir, file)
|
|
645
|
+
FileUtils.cp(File.join(source_dir, "scripts", file), dest)
|
|
646
|
+
@results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
|
|
647
|
+
elsif @upgrade && provenance.read then { status: "updated" }
|
|
648
|
+
else { status: "installed" }
|
|
649
|
+
end
|
|
650
|
+
FileUtils.chmod(0o755, dest)
|
|
651
|
+
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
652
|
+
expected = declared.to_s.delete_prefix("sha256:")
|
|
653
|
+
if @strict && !expected.empty? && actual != expected
|
|
654
|
+
@warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
655
|
+
end
|
|
656
|
+
[file, dest]
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
618
660
|
# A model overlay (<name>.<key>.md, docs/memory.md) loads only with
|
|
619
661
|
# its base, under the matching model: it gets no index line (every
|
|
620
662
|
# model would see it as a memory of its own). Its base is looked for
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "digest"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require_relative "../context_providers"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module MemoryBundle
|
|
@@ -25,6 +26,15 @@ module Samagotchi
|
|
|
25
26
|
# why: reads PRs # optional
|
|
26
27
|
# hint: brew install gh # optional
|
|
27
28
|
# - jq # short form: just the command
|
|
29
|
+
# scripts: # optional — files in scripts/ a context
|
|
30
|
+
# pr_context.rb: sha256:… # provider's command runs (installed into
|
|
31
|
+
# # the bundle's own dir, scripts/)
|
|
32
|
+
# context_providers: # optional — URL → attached context source
|
|
33
|
+
# - match: '\Ahttps://…/pull/(\d+)' # (ContextProviders)
|
|
34
|
+
# name: 'pr-\1'
|
|
35
|
+
# cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
|
|
36
|
+
# why: GitHub PR # optional
|
|
37
|
+
# every_seconds: 300 # optional
|
|
28
38
|
# includes: [a, b] # optional — a profile (meta bundle): the
|
|
29
39
|
# # shipped bundles it installs; its dir holds
|
|
30
40
|
# # only manifest.yml (MemoryBundle::Profile)
|
|
@@ -33,12 +43,15 @@ module Samagotchi
|
|
|
33
43
|
|
|
34
44
|
# A plugin file is a plain .rb name in the bundle's top directory.
|
|
35
45
|
PLUGIN_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\.rb\z/
|
|
46
|
+
# A script is a plain file name in the bundle's scripts/.
|
|
47
|
+
SCRIPT_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
|
|
36
48
|
# A need is a plain executable name: no path, no spaces.
|
|
37
49
|
NEED_COMMAND = /\A[A-Za-z0-9][A-Za-z0-9._+-]*\z/
|
|
38
50
|
# A bundle name, as `chi bundle install <name>` takes a shipped one.
|
|
39
51
|
BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
40
52
|
|
|
41
|
-
attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes
|
|
53
|
+
attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
54
|
+
:scripts, :context_providers
|
|
42
55
|
|
|
43
56
|
def initialize(path:)
|
|
44
57
|
@path = path
|
|
@@ -53,6 +66,8 @@ module Samagotchi
|
|
|
53
66
|
@plugin = parse_plugin(raw["plugin"])
|
|
54
67
|
@requires_chi = parse_requires_chi(raw["requires_chi"])
|
|
55
68
|
@needs = self.class.parse_needs(raw["needs"])
|
|
69
|
+
@scripts = parse_scripts(raw["scripts"])
|
|
70
|
+
@context_providers = parse_context_providers(raw["context_providers"])
|
|
56
71
|
@includes = parse_includes(raw["includes"])
|
|
57
72
|
end
|
|
58
73
|
|
|
@@ -109,7 +124,7 @@ module Samagotchi
|
|
|
109
124
|
|
|
110
125
|
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
111
126
|
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
112
|
-
plugin: nil, requires_chi: nil, needs: nil)
|
|
127
|
+
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [])
|
|
113
128
|
FileUtils.mkdir_p(dir)
|
|
114
129
|
manifest = {
|
|
115
130
|
"name" => name,
|
|
@@ -134,6 +149,8 @@ module Samagotchi
|
|
|
134
149
|
manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
135
150
|
needs = parse_needs(needs)
|
|
136
151
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
152
|
+
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
153
|
+
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
137
154
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
138
155
|
end
|
|
139
156
|
|
|
@@ -213,6 +230,27 @@ module Samagotchi
|
|
|
213
230
|
{ file: file, sha256: sha }
|
|
214
231
|
end
|
|
215
232
|
|
|
233
|
+
# scripts: {file => sha256} → the same, "sha256:" prefixed; {} when absent.
|
|
234
|
+
def parse_scripts(raw)
|
|
235
|
+
return {} if raw.nil?
|
|
236
|
+
raise ValidationError, "scripts: must be a mapping of file name to sha256" unless raw.is_a?(Hash)
|
|
237
|
+
|
|
238
|
+
raw.to_h do |file, sha|
|
|
239
|
+
file = file.to_s
|
|
240
|
+
raise ValidationError, "scripts: #{file.inspect} isn't a plain file name in scripts/" unless file.match?(SCRIPT_FILE)
|
|
241
|
+
|
|
242
|
+
sha = sha.to_s.strip
|
|
243
|
+
[file, sha.empty? || sha.start_with?("sha256:") ? sha : "sha256:#{sha}"]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @return [Array<ContextProviders::Provider>]
|
|
248
|
+
def parse_context_providers(raw)
|
|
249
|
+
ContextProviders.parse_list(raw)
|
|
250
|
+
rescue ContextProviders::Invalid => e
|
|
251
|
+
raise ValidationError, e.message
|
|
252
|
+
end
|
|
253
|
+
|
|
216
254
|
# includes: → the names in order, duplicates dropped; [] when absent.
|
|
217
255
|
# A meta ships nothing of its own, so files, hooks or a plugin next
|
|
218
256
|
# to it is a ValidationError.
|
|
@@ -226,8 +264,8 @@ module Samagotchi
|
|
|
226
264
|
|
|
227
265
|
name
|
|
228
266
|
end.uniq
|
|
229
|
-
if !names.empty? && (!@files.empty? || !@hooks.empty? || @plugin)
|
|
230
|
-
raise ValidationError, "a bundle with includes: holds only its includes (no files, hooks or
|
|
267
|
+
if !names.empty? && (!@files.empty? || !@hooks.empty? || @plugin || !@scripts.empty?)
|
|
268
|
+
raise ValidationError, "a bundle with includes: holds only its includes (no files, hooks, plugin or scripts)"
|
|
231
269
|
end
|
|
232
270
|
|
|
233
271
|
names
|
|
@@ -39,6 +39,12 @@ module Samagotchi
|
|
|
39
39
|
File.join(@bundle_dir, "plugin")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# The installed scripts' dir (scripts/<file>): what a context
|
|
43
|
+
# provider's command finds as {bundle_dir}/scripts.
|
|
44
|
+
def scripts_dir
|
|
45
|
+
File.join(@bundle_dir, "scripts")
|
|
46
|
+
end
|
|
47
|
+
|
|
42
48
|
# The plugin file as installed, or nil when the bundle has none.
|
|
43
49
|
def plugin_path(data = read)
|
|
44
50
|
file = data && data[:plugin].is_a?(Hash) ? data[:plugin][:file].to_s : ""
|
|
@@ -129,8 +135,12 @@ module Samagotchi
|
|
|
129
135
|
# edits that conflict with it; their entries get conflict: true
|
|
130
136
|
# @param includes [Array<String>, nil] a profile's recorded members
|
|
131
137
|
# (MemoryBundle::Profile); nil for a plain bundle
|
|
138
|
+
# @param scripts_files [Hash] file name => installed script; the
|
|
139
|
+
# sha256 of each is recorded (ContextFetch checks it before a run)
|
|
140
|
+
# @param context_providers [Array<ContextProviders::Provider>]
|
|
132
141
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
133
|
-
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil
|
|
142
|
+
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
|
|
143
|
+
scripts_files: {}, context_providers: [])
|
|
134
144
|
FileUtils.mkdir_p(@bundle_dir)
|
|
135
145
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
136
146
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -241,6 +251,12 @@ module Samagotchi
|
|
|
241
251
|
manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
242
252
|
manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
|
|
243
253
|
manifest_data["includes"] = includes.map(&:to_s) if includes.is_a?(Array)
|
|
254
|
+
unless scripts_files.empty?
|
|
255
|
+
manifest_data["scripts"] = scripts_files.sort.to_h do |file, path|
|
|
256
|
+
[file.to_s, { "sha256" => "sha256:#{Digest::SHA256.hexdigest(File.binread(path.to_s))}" }]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
244
260
|
|
|
245
261
|
write_manifest(manifest_data)
|
|
246
262
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../context_sources"
|
|
5
|
+
require_relative "../context_providers"
|
|
6
|
+
require_relative "../session"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Plugin
|
|
10
|
+
# ctx.context (docs/plugins.md, Attached context): context a plugin
|
|
11
|
+
# attaches to its session (ContextSources), through an installed
|
|
12
|
+
# bundle's provider (a URL) or as its own command. In-process, like any
|
|
13
|
+
# plugin code: not gated as `chi context add --cmd` through execute is.
|
|
14
|
+
# The session's worker runs and absorbs it (ContextPoller).
|
|
15
|
+
class AttachedContext
|
|
16
|
+
# An attach that couldn't be done; the message says why.
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
# @param host [Host] session_id and state_dir
|
|
20
|
+
def initialize(host, bundle:)
|
|
21
|
+
@host = host
|
|
22
|
+
@bundle = bundle
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Attach a source to this session. +url+: through an installed
|
|
26
|
+
# bundle's provider (+name+ and +why+ replace the provider's); else
|
|
27
|
+
# +name+ and +cmd+ (+hint+, +every_seconds+). A source of that name
|
|
28
|
+
# already attached stays as it is. One the user removed from the
|
|
29
|
+
# session (its name, or its URL) isn't attached again (#declined?)
|
|
30
|
+
# until the user adds it again.
|
|
31
|
+
# @return [String, nil] the source's name; nil when declined
|
|
32
|
+
# @raise [Error]
|
|
33
|
+
def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil)
|
|
34
|
+
own = location or raise Error, "this session has no id yet"
|
|
35
|
+
source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
|
|
36
|
+
return nil if own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
|
|
37
|
+
return source.name if own.source(source.name)
|
|
38
|
+
|
|
39
|
+
own.add(source.with(scope: own.scope))
|
|
40
|
+
source.name
|
|
41
|
+
rescue ContextSources::Invalid, ContextProviders::Invalid => e
|
|
42
|
+
raise Error, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whether the user removed +name+ from this session (so #attach skips it).
|
|
46
|
+
def declined?(name)
|
|
47
|
+
own = location
|
|
48
|
+
own ? own.declined?(name) : false
|
|
49
|
+
rescue ContextSources::Invalid
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# This session's sources (its own, then its project's).
|
|
54
|
+
# @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:}
|
|
55
|
+
def list
|
|
56
|
+
id = @host.session_id.call
|
|
57
|
+
return [] unless id
|
|
58
|
+
|
|
59
|
+
session = Session.load(id, state_dir: state_dir)
|
|
60
|
+
ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
|
|
61
|
+
snapshot = attached.snapshot
|
|
62
|
+
{ name: attached.name, scope: attached.location.scope, why: attached.source.why,
|
|
63
|
+
hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
|
|
64
|
+
provider: attached.source.provider }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def state_dir = @host.state_dir&.call || Session.default_state_dir
|
|
71
|
+
|
|
72
|
+
def location
|
|
73
|
+
id = @host.session_id.call
|
|
74
|
+
id && ContextSources.session_location(id, state_dir: state_dir)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def from_url(url, name:, why:)
|
|
78
|
+
resolved = ContextProviders.resolve(url) or raise Error, "no installed bundle resolves #{url}"
|
|
79
|
+
source(name: name ? ContextSources.check_name!(name) : resolved.name, cmd: resolved.cmd, why: why || resolved.why,
|
|
80
|
+
hint: resolved.hint, every_seconds: resolved.every_seconds, provider: resolved.bundle)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def from_cmd(name:, cmd:, why:, hint:, every_seconds:)
|
|
84
|
+
raise Error, "give url: or cmd: (with name:)" if cmd.to_s.strip.empty?
|
|
85
|
+
|
|
86
|
+
source(name: ContextSources.check_name!(name), cmd: cmd.to_s, why: why, hint: hint,
|
|
87
|
+
every_seconds: ContextSources.check_every!(every_seconds), provider: nil)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def source(name:, cmd:, why:, hint:, every_seconds:, provider:)
|
|
91
|
+
ContextSources::Source.new(
|
|
92
|
+
name: name, cmd: cmd, every_seconds: every_seconds,
|
|
93
|
+
why: ContextSources.one_line(why, ContextSources::LINE_MAX_CHARS),
|
|
94
|
+
hint: ContextSources.one_line(hint, ContextSources::LINE_MAX_CHARS), scope: nil,
|
|
95
|
+
added_by: "plugin:#{@bundle}", created_at: Time.now.utc.iso8601, provider: provider
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -7,6 +7,7 @@ require_relative "../guardrails/context"
|
|
|
7
7
|
require_relative "../idle_client"
|
|
8
8
|
require_relative "side_question"
|
|
9
9
|
require_relative "sessions"
|
|
10
|
+
require_relative "attached_context"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
module Plugin
|
|
@@ -98,6 +99,20 @@ module Samagotchi
|
|
|
98
99
|
# current_model_only names it
|
|
99
100
|
def model_key = @host.model_key&.call
|
|
100
101
|
|
|
102
|
+
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
103
|
+
def scratch? = !!@host.scratch&.call
|
|
104
|
+
|
|
105
|
+
# Whether the session is a delegate child (Session#delegate?): a task
|
|
106
|
+
# another session handed over, not the user's own.
|
|
107
|
+
def delegate?
|
|
108
|
+
id = session_id
|
|
109
|
+
return false unless id
|
|
110
|
+
|
|
111
|
+
Session.load(id, state_dir: @host.state_dir&.call || Session.default_state_dir).delegate?
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
101
116
|
# @return [String] the session's working directory
|
|
102
117
|
def cwd = @host.cwd.call || Dir.pwd
|
|
103
118
|
|
|
@@ -210,6 +225,13 @@ module Samagotchi
|
|
|
210
225
|
@sessions ||= Sessions.new(@host)
|
|
211
226
|
end
|
|
212
227
|
|
|
228
|
+
# This session's attached context: attach a URL or a command, list
|
|
229
|
+
# (AttachedContext).
|
|
230
|
+
# @return [AttachedContext]
|
|
231
|
+
def context
|
|
232
|
+
@context ||= AttachedContext.new(@host, bundle: @bundle)
|
|
233
|
+
end
|
|
234
|
+
|
|
213
235
|
# A single-select question through the question flow. Inside a
|
|
214
236
|
# stream hook's handler (generation_progress) it asks no one (nil).
|
|
215
237
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../client_id"
|
|
3
4
|
require_relative "../session"
|
|
4
5
|
require_relative "../bridge_client"
|
|
5
6
|
require_relative "../bridge/turn_accumulator"
|
|
@@ -62,7 +63,7 @@ module Samagotchi
|
|
|
62
63
|
|
|
63
64
|
state_dir = self.state_dir
|
|
64
65
|
sid = resolve(id, state_dir)
|
|
65
|
-
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id:
|
|
66
|
+
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir)
|
|
66
67
|
case delivered[:status]
|
|
67
68
|
when :accepted then sid
|
|
68
69
|
when :refused then raise Error, "session #{sid[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A command run in a process group of its own, so a stop reaches what it
|
|
5
|
+
# started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
|
|
6
|
+
# and ContextFetch spawn and stop their commands here, each with its own
|
|
7
|
+
# grace and its own "has it stopped?" check (the leader gone, the pipes
|
|
8
|
+
# closed, the pid reaped).
|
|
9
|
+
#
|
|
10
|
+
# The mcp bundle's client keeps its own copy (stdin closed first, then
|
|
11
|
+
# the group): a bundle may use chi's classes only from the chi its
|
|
12
|
+
# requires_chi names, and no released chi has this one yet.
|
|
13
|
+
module ProcessGroup
|
|
14
|
+
# A group that is gone, or one that isn't ours to signal.
|
|
15
|
+
QUIET = [Errno::ESRCH, Errno::EPERM].freeze
|
|
16
|
+
READ_CHUNK_BYTES = 65_536
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Process.spawn in a new process group, whose id is the pid.
|
|
21
|
+
# @return [Integer] the pid
|
|
22
|
+
def spawn(env, *argv, **) = Process.spawn(env, *argv, pgroup: true, **)
|
|
23
|
+
|
|
24
|
+
# Sends +sig+ to every process in the group +pgid+.
|
|
25
|
+
# @param leader [Boolean] when the group can't be signalled, signal the
|
|
26
|
+
# process +pgid+ alone (a leader that left its group)
|
|
27
|
+
# @param quiet [Array<Class>] the errors that mean there is nothing to
|
|
28
|
+
# signal; any other is raised
|
|
29
|
+
# @return [Boolean] whether a signal was sent
|
|
30
|
+
def signal(pgid, sig, leader: false, quiet: QUIET)
|
|
31
|
+
check!(pgid)
|
|
32
|
+
Process.kill(sig, -pgid)
|
|
33
|
+
true
|
|
34
|
+
rescue *quiet
|
|
35
|
+
return false unless leader
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
Process.kill(sig, pgid)
|
|
39
|
+
true
|
|
40
|
+
rescue *quiet
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A pid chi may signal or ask about: an Integer above 1. kill(0) and
|
|
46
|
+
# kill(-1) reach chi's own group and every process of the user, so a
|
|
47
|
+
# pid that isn't one (a broken or edited record) never gets that far.
|
|
48
|
+
def signalable?(pid) = pid.is_a?(Integer) && pid > 1
|
|
49
|
+
|
|
50
|
+
# @raise [ArgumentError] unless #signalable?
|
|
51
|
+
def check!(pid)
|
|
52
|
+
raise ArgumentError, "not a process group chi may signal: #{pid.inspect}" unless signalable?(pid)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether +pid+ runs and leads its own group, as #spawn's process does
|
|
56
|
+
# (not, then, a reused pid in some other group).
|
|
57
|
+
def leader?(pid)
|
|
58
|
+
signalable?(pid) && Process.getpgid(pid) == pid
|
|
59
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether +pid+ is still running (or a zombie not reaped yet).
|
|
64
|
+
# @raise [ArgumentError] unless #signalable?
|
|
65
|
+
def alive?(pid)
|
|
66
|
+
check!(pid)
|
|
67
|
+
Process.kill(0, pid)
|
|
68
|
+
true
|
|
69
|
+
rescue Errno::ESRCH
|
|
70
|
+
false
|
|
71
|
+
rescue Errno::EPERM
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# TERMs the group, waits up to +grace+ for +stopped+, then KILLs it.
|
|
76
|
+
# @param stopped [#call] true once it has stopped (default: the leader
|
|
77
|
+
# is gone)
|
|
78
|
+
# @param wait [#call] (seconds) between two checks (default: a sleep)
|
|
79
|
+
# @param signal_options [Hash] for #signal (leader:, quiet:)
|
|
80
|
+
# @return [Boolean] true when it stopped within the grace (no KILL sent)
|
|
81
|
+
# @raise [ArgumentError] unless #signalable?
|
|
82
|
+
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) }, **signal_options)
|
|
83
|
+
check!(pgid)
|
|
84
|
+
signal(pgid, "TERM", **signal_options)
|
|
85
|
+
deadline = monotonic + grace
|
|
86
|
+
until (done = stopped.call) || monotonic >= deadline
|
|
87
|
+
wait.call(poll)
|
|
88
|
+
end
|
|
89
|
+
return true if done
|
|
90
|
+
|
|
91
|
+
signal(pgid, "KILL", **signal_options)
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
|
|
97
|
+
# Reads a pipe in chunks on a thread of its own, so the output read so
|
|
98
|
+
# far survives the pipe being closed under it (or the thread killed).
|
|
99
|
+
class PipeReader
|
|
100
|
+
# @param cap [Integer, nil] the most bytes it keeps
|
|
101
|
+
# @param keep [:head, :tail] past the cap: :head stops reading (and
|
|
102
|
+
# #over? is true), :tail keeps the last +cap+ bytes
|
|
103
|
+
def initialize(io, cap: nil, keep: :head, chunk: READ_CHUNK_BYTES)
|
|
104
|
+
@io = io
|
|
105
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
106
|
+
@over = false
|
|
107
|
+
@thread = Thread.new do
|
|
108
|
+
Thread.current.report_on_exception = false
|
|
109
|
+
loop { break unless take(io.readpartial(chunk), cap, keep) }
|
|
110
|
+
rescue IOError # EOFError is one
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def done? = !@thread.alive?
|
|
116
|
+
|
|
117
|
+
# Whether a :head reader stopped at its cap.
|
|
118
|
+
def over? = @over
|
|
119
|
+
|
|
120
|
+
# @return [Thread, nil] the thread when it ended within +seconds+
|
|
121
|
+
def wait(seconds) = @thread.join(seconds)
|
|
122
|
+
|
|
123
|
+
def kill = @thread.kill
|
|
124
|
+
|
|
125
|
+
# Closes the pipe once the thread had +poll+ to end by itself (a
|
|
126
|
+
# reader still blocked gets IOError), then ends the thread, killing it
|
|
127
|
+
# after +grace+. The text is safe to read afterwards.
|
|
128
|
+
def finish(poll:, grace:)
|
|
129
|
+
@thread.join(poll) unless done?
|
|
130
|
+
close_quietly
|
|
131
|
+
@thread.kill unless @thread.join(grace)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# What it read, in +encoding+ (default: the pipe's).
|
|
135
|
+
def text(encoding = @io.external_encoding || Encoding.default_external)
|
|
136
|
+
@buffer.dup.force_encoding(encoding)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# @return [Boolean] whether to read on
|
|
142
|
+
def take(chunk, cap, keep)
|
|
143
|
+
if cap && keep == :head && @buffer.bytesize + chunk.bytesize > cap
|
|
144
|
+
@over = true
|
|
145
|
+
return false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
@buffer << chunk
|
|
149
|
+
@buffer = @buffer.byteslice(-cap, cap) if cap && keep == :tail && @buffer.bytesize > cap
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def close_quietly
|
|
154
|
+
@io.close unless @io.closed?
|
|
155
|
+
rescue IOError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|