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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +220 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +7 -3
  10. data/docs/plugins.md +79 -2
  11. data/docs/releasing.md +3 -2
  12. data/lib/samagotchi/answer_command.rb +2 -1
  13. data/lib/samagotchi/archive_store.rb +1 -15
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge.rb +8 -3
  16. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  17. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  18. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  19. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  20. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  21. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  26. data/lib/samagotchi/child_reports.rb +31 -12
  27. data/lib/samagotchi/client_id.rb +52 -0
  28. data/lib/samagotchi/config.rb +6 -0
  29. data/lib/samagotchi/context_absorber.rb +180 -0
  30. data/lib/samagotchi/context_command.rb +473 -0
  31. data/lib/samagotchi/context_fetch.rb +195 -0
  32. data/lib/samagotchi/context_note.rb +11 -5
  33. data/lib/samagotchi/context_poller.rb +100 -0
  34. data/lib/samagotchi/context_providers.rb +159 -0
  35. data/lib/samagotchi/context_sources.rb +462 -0
  36. data/lib/samagotchi/engine.rb +19 -11
  37. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  38. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
  39. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  40. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  41. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  45. data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
  46. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  47. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  48. data/lib/samagotchi/plugin/context.rb +22 -0
  49. data/lib/samagotchi/plugin/sessions.rb +2 -1
  50. data/lib/samagotchi/process_group.rb +160 -0
  51. data/lib/samagotchi/prompt_warmup.rb +20 -1
  52. data/lib/samagotchi/question_desk.rb +2 -4
  53. data/lib/samagotchi/relay_verifier.rb +2 -4
  54. data/lib/samagotchi/reply_wait.rb +9 -4
  55. data/lib/samagotchi/send_command.rb +2 -1
  56. data/lib/samagotchi/session.rb +7 -5
  57. data/lib/samagotchi/session_commands.rb +18 -1
  58. data/lib/samagotchi/session_manager.rb +7 -6
  59. data/lib/samagotchi/session_retention.rb +3 -0
  60. data/lib/samagotchi/steer.rb +36 -19
  61. data/lib/samagotchi/system_prompt.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
  63. data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
  64. data/lib/samagotchi/terminal_ui.rb +2 -4
  65. data/lib/samagotchi/tool_activity.rb +8 -0
  66. data/lib/samagotchi/tool_declarations.rb +21 -0
  67. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  68. data/lib/samagotchi/tools/builtins.rb +5 -0
  69. data/lib/samagotchi/tools/context_read.rb +163 -0
  70. data/lib/samagotchi/tools/delegate.rb +2 -2
  71. data/lib/samagotchi/tools/execute.rb +10 -81
  72. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +111 -2
  75. data/lib/samagotchi/web/public/app.js +57 -28
  76. data/lib/samagotchi/web/public/composer_size.js +7 -6
  77. data/lib/samagotchi/web/public/context_chips.js +285 -0
  78. data/lib/samagotchi/web/public/data.js +24 -0
  79. data/lib/samagotchi/web/public/index.html +49 -3
  80. data/lib/samagotchi/web/public/notify.js +5 -3
  81. data/lib/samagotchi/web/public/question_card.js +3 -4
  82. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  83. data/lib/samagotchi/web/public/stable_html.js +18 -0
  84. data/lib/samagotchi/web/public/timing.js +7 -3
  85. data/lib/samagotchi/web/public/turn_events.js +16 -3
  86. data/lib/samagotchi/web/public/turn_view.js +5 -2
  87. data/lib/samagotchi/worker.rb +154 -6
  88. 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
- @scopes = picked.empty? ? SCOPES : picked
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
@@ -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 plugin)"
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: "plugin", state_dir: state_dir)
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