samagotchi 0.131.0 → 0.133.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 +88 -1
- data/bin/chi +19 -1
- data/docs/architecture.md +59 -1
- data/docs/cli.md +29 -8
- data/docs/configuration.md +115 -16
- data/docs/desktop.md +16 -6
- data/docs/guardrails.md +10 -4
- data/docs/internals/anthropic.md +3 -2
- data/docs/internals/engine.md +4 -4
- data/docs/internals/prompt-caching.md +26 -6
- data/docs/memory.md +16 -2
- data/docs/plugins.md +68 -1
- data/docs/sessions.md +60 -6
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +35 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
- data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
- data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cache_confirm.rb +18 -0
- data/lib/samagotchi/chi_home.rb +311 -0
- data/lib/samagotchi/chi_shim.rb +77 -0
- data/lib/samagotchi/child_env.rb +5 -3
- data/lib/samagotchi/config.rb +2 -0
- data/lib/samagotchi/context_fetch.rb +4 -2
- data/lib/samagotchi/desktop/macos.rb +100 -18
- data/lib/samagotchi/engine.rb +35 -4
- data/lib/samagotchi/engine_snapshots.rb +5 -0
- data/lib/samagotchi/gem_update.rb +19 -10
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
- data/lib/samagotchi/guardrails/pair_command.rb +57 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
- data/lib/samagotchi/guardrails/review_paths.rb +7 -4
- data/lib/samagotchi/guardrails/verdict.rb +1 -1
- data/lib/samagotchi/home_command.rb +145 -0
- data/lib/samagotchi/home_doctor.rb +177 -0
- data/lib/samagotchi/home_notice.rb +80 -0
- data/lib/samagotchi/installed_versions.rb +5 -3
- data/lib/samagotchi/llm_context_command.rb +53 -7
- data/lib/samagotchi/llm_context_override.rb +3 -2
- data/lib/samagotchi/plugin/api.rb +45 -1
- data/lib/samagotchi/plugin/context.rb +34 -1
- data/lib/samagotchi/plugin/loader.rb +5 -2
- data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
- data/lib/samagotchi/project_env.rb +360 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/self_report.rb +33 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +68 -19
- data/lib/samagotchi/session_manager.rb +54 -1
- data/lib/samagotchi/session_model_settings.rb +5 -2
- data/lib/samagotchi/system_prompt.rb +34 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/testing/boundary.rb +3 -1
- data/lib/samagotchi/testing/bundle.rb +22 -3
- data/lib/samagotchi/testing/host.rb +22 -1
- data/lib/samagotchi/testing/recorder.rb +6 -1
- data/lib/samagotchi/testing/recording_api.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +13 -2
- data/lib/samagotchi/update_command.rb +65 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +61 -1
- data/lib/samagotchi/web/public/app.js +4 -1
- data/lib/samagotchi/web/public/chip_popover.js +5 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/index.html +52 -10
- data/lib/samagotchi/web/public/level_chip.js +22 -38
- data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
- data/lib/samagotchi/web/public/model_pick.js +26 -0
- data/lib/samagotchi/web/public/model_picker.js +3 -1
- data/lib/samagotchi/web/public/review_start.js +13 -57
- data/lib/samagotchi/web/public/send_turn.js +3 -2
- data/lib/samagotchi/web/public/start_page.js +131 -99
- data/lib/samagotchi/web/public/start_settings.js +298 -0
- data/lib/samagotchi/web/public/start_toolkit.js +157 -0
- data/lib/samagotchi/web/public/thinking_chip.js +9 -22
- data/lib/samagotchi/web/server.rb +27 -8
- data/lib/samagotchi/web/update_check.rb +3 -2
- metadata +11 -1
|
@@ -9,6 +9,7 @@ require "rbconfig"
|
|
|
9
9
|
require_relative "../atomic_file"
|
|
10
10
|
require_relative "../version"
|
|
11
11
|
require_relative "../self_report"
|
|
12
|
+
require_relative "../chi_home"
|
|
12
13
|
require_relative "../installed_gem"
|
|
13
14
|
require_relative "../config"
|
|
14
15
|
require_relative "../host_keys"
|
|
@@ -41,9 +42,16 @@ module Samagotchi
|
|
|
41
42
|
BUILD_NAME = ".chi-helper-build"
|
|
42
43
|
OLD_NAME = ".chi-helper-old"
|
|
43
44
|
# Apps started by launchd get a bare env (no shell rc files), so the
|
|
44
|
-
# launch file carries what chi needs to find its
|
|
45
|
-
# Only these, and only when set: no tokens, no runtime knobs.
|
|
46
|
-
|
|
45
|
+
# launch file carries what chi needs to find its state and tools.
|
|
46
|
+
# Only these, and only when set: no tokens, no runtime knobs. No
|
|
47
|
+
# RUBYLIB, and GEM_HOME/GEM_PATH (GEM_ENV) only for a [ruby, chi]
|
|
48
|
+
# argv, whose wrapper may find its gem only through them (a chruby
|
|
49
|
+
# GEM_HOME): the chi home's boot sets its gem path itself, and they
|
|
50
|
+
# would reach every tool its sessions run (a project's `bundle exec`).
|
|
51
|
+
ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME].freeze
|
|
52
|
+
GEM_ENV = %w[GEM_HOME GEM_PATH].freeze
|
|
53
|
+
# What older launch files carried and a refresh drops (#refresh_launch_file).
|
|
54
|
+
DROPPED_ENV = %w[GEM_HOME GEM_PATH RUBYLIB].freeze
|
|
47
55
|
# PATH is separately filtered to absolute dirs and frozen when written,
|
|
48
56
|
# so the helper's chi, workers and execute can find the shell's tools.
|
|
49
57
|
# Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
|
|
@@ -131,7 +139,7 @@ module Samagotchi
|
|
|
131
139
|
def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
|
|
132
140
|
source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
|
|
133
141
|
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, terminals: {},
|
|
134
|
-
broadcast_timeout: nil)
|
|
142
|
+
broadcast_timeout: nil, chi_home: ChiHome.current(gem_spec: InstalledGem.spec(source_dir)))
|
|
135
143
|
home = env["HOME"] || Dir.home
|
|
136
144
|
@app_dir = app_dir || File.join(home, "Applications")
|
|
137
145
|
@support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
|
|
@@ -146,6 +154,7 @@ module Samagotchi
|
|
|
146
154
|
@register = register
|
|
147
155
|
@terminals = terminals
|
|
148
156
|
@broadcast_timeout = broadcast_timeout
|
|
157
|
+
@chi_home = chi_home
|
|
149
158
|
end
|
|
150
159
|
|
|
151
160
|
def app_path = File.join(@app_dir, "#{APP_NAME}.app")
|
|
@@ -155,10 +164,12 @@ module Samagotchi
|
|
|
155
164
|
def executable_path = File.join(app_path, "Contents", "MacOS", EXECUTABLE)
|
|
156
165
|
def installed? = File.directory?(app_path)
|
|
157
166
|
|
|
158
|
-
# What the helper runs:
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
167
|
+
# What the helper runs: the chi home's boot (ChiHome: its ruby, its gem
|
|
168
|
+
# path in the -e string, then "--" and the helper's args) when there
|
|
169
|
+
# is one; else absolute ruby + chi (no shims, which need the shell's
|
|
170
|
+
# PATH). chi is an installed gem's RubyGems wrapper (it activates the
|
|
171
|
+
# gem and outlives upgrades and `gem cleanup`), else this checkout's
|
|
172
|
+
# bin/chi. And the allowlisted env. A section per terminal
|
|
162
173
|
# switched on (TERMINALS order); the broadcast timeout when given.
|
|
163
174
|
# The XDG dirs go in absolute (Paths): launchd starts the app in /,
|
|
164
175
|
# where a relative one would mean /<dir>. PATH is filtered to absolute,
|
|
@@ -166,16 +177,22 @@ module Samagotchi
|
|
|
166
177
|
# it is frozen at bake time.
|
|
167
178
|
def launch_config
|
|
168
179
|
env = { "LANG" => LANG }
|
|
169
|
-
|
|
180
|
+
argv = launch_argv
|
|
181
|
+
names = home_boot?(argv) ? ENV_ALLOWLIST : ENV_ALLOWLIST + GEM_ENV
|
|
182
|
+
names.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
170
183
|
path = self.class.baked_path(@env)
|
|
171
184
|
env["PATH"] = path if path
|
|
172
185
|
env.merge!(Paths.absolute_xdg(env: env, set: :key))
|
|
173
|
-
config = { "version" => @version, "argv" =>
|
|
186
|
+
config = { "version" => @version, "argv" => argv, "env" => env, "sources_sha" => sources_sha }
|
|
174
187
|
TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
|
|
175
188
|
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
|
176
189
|
config
|
|
177
190
|
end
|
|
178
191
|
|
|
192
|
+
def launch_argv
|
|
193
|
+
@chi_home ? @chi_home.boot_argv(run: ChiHome::CHI_BOOT) + ["--"] : [@ruby, @chi_path]
|
|
194
|
+
end
|
|
195
|
+
|
|
179
196
|
# A digest of what a build compiles: the Swift sources and the
|
|
180
197
|
# Info.plist template. The launch file records the one it was built
|
|
181
198
|
# from, so a new chi rebuilds only when these changed.
|
|
@@ -209,13 +226,37 @@ module Samagotchi
|
|
|
209
226
|
|
|
210
227
|
# Why chi update can't keep the launch file's ruby or chi: one of them
|
|
211
228
|
# is gone. nil when it runs, or has no usable argv (a refresh writes
|
|
212
|
-
# this chi's then).
|
|
229
|
+
# this chi's then). Only the absolute paths in it count (a boot
|
|
230
|
+
# string, "-e" and "--" are not paths), as ChiRunner.swift checks.
|
|
213
231
|
def launch_problem
|
|
214
232
|
argv = usable_argv(read_launch)
|
|
215
|
-
|
|
216
|
-
|
|
233
|
+
argv && argv_problem(argv)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Why the launch file doesn't boot +home+ (chi home doctor), or nil:
|
|
237
|
+
# it runs [ruby, chi] (installed before the home), boots another Ruby
|
|
238
|
+
# or gem path (the home was set since), or bakes GEM_HOME/GEM_PATH
|
|
239
|
+
# under the home's boot. Each fixed by chi desktop upgrade. nil without
|
|
240
|
+
# a launch file.
|
|
241
|
+
# @param home [ChiHome::Record]
|
|
242
|
+
# @return [String, nil]
|
|
243
|
+
def home_problem(home)
|
|
244
|
+
launch = read_launch
|
|
245
|
+
argv = usable_argv(launch)
|
|
246
|
+
return nil unless argv
|
|
247
|
+
|
|
248
|
+
unless home_boot?(argv)
|
|
249
|
+
return "the desktop helper runs #{argv.first(2).join(" ")}, not the chi home's boot: chi desktop upgrade"
|
|
250
|
+
end
|
|
251
|
+
unless ChiHome.real(argv[0]) == ChiHome.real(home.ruby) && argv[2].to_s.start_with?("#{home.prelude};")
|
|
252
|
+
return "the desktop helper boots the chi home as it was (#{argv[0]}), not as it is now: chi desktop upgrade"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
env = launch["env"]
|
|
256
|
+
baked = env.is_a?(Hash) ? GEM_ENV.select { |key| env.key?(key) } : []
|
|
257
|
+
return nil if baked.empty?
|
|
217
258
|
|
|
218
|
-
"
|
|
259
|
+
"the desktop helper's launch file sets #{baked.join(", ")} for its sessions' tools: chi desktop upgrade"
|
|
219
260
|
end
|
|
220
261
|
|
|
221
262
|
# The chi version the launch file names, nil without one.
|
|
@@ -232,7 +273,10 @@ module Samagotchi
|
|
|
232
273
|
# Rewrite the launch file for this chi and its settings, keeping
|
|
233
274
|
# its argv and the env it was installed with; a PATH only when it has
|
|
234
275
|
# none (changing them takes chi desktop install --force or upgrade). A
|
|
235
|
-
# newer version stays. The app reads it at each send.
|
|
276
|
+
# newer version stays. The app reads it at each send. The kept env
|
|
277
|
+
# loses what ENV_ALLOWLIST no longer takes (DROPPED_ENV): RUBYLIB
|
|
278
|
+
# always; GEM_HOME/GEM_PATH once the argv boots the chi home (an old
|
|
279
|
+
# [ruby, wrapper] argv may find its gem only through them).
|
|
236
280
|
def refresh_launch_file
|
|
237
281
|
config = launch_config
|
|
238
282
|
old = read_launch
|
|
@@ -240,7 +284,8 @@ module Samagotchi
|
|
|
240
284
|
config["version"] = old["version"] unless older_version?(old["version"])
|
|
241
285
|
old_env = old["env"]
|
|
242
286
|
if old_env.is_a?(Hash)
|
|
243
|
-
config["
|
|
287
|
+
dropped = home_boot?(config["argv"]) ? DROPPED_ENV : DROPPED_ENV - GEM_ENV
|
|
288
|
+
config["env"] = old_env.except(*dropped)
|
|
244
289
|
path = self.class.baked_path(@env)
|
|
245
290
|
config["env"]["PATH"] = path if config["env"]["PATH"].to_s.empty? && path
|
|
246
291
|
end
|
|
@@ -466,11 +511,41 @@ module Samagotchi
|
|
|
466
511
|
!env.is_a?(Hash) || env["PATH"].to_s.empty?
|
|
467
512
|
end
|
|
468
513
|
|
|
514
|
+
# A launch file whose argv runs (#argv_problem finds nothing).
|
|
469
515
|
def launch_ok?(launch)
|
|
470
|
-
argv =
|
|
471
|
-
!argv.
|
|
516
|
+
argv = usable_argv(launch)
|
|
517
|
+
!argv.nil? && argv_problem(argv).nil?
|
|
472
518
|
end
|
|
473
519
|
|
|
520
|
+
# Why a launch argv (strings) can't run chi, or nil: its ruby isn't an
|
|
521
|
+
# absolute path, an absolute path in it is gone, or it boots the chi
|
|
522
|
+
# home (home.json's ruby) and the home can't boot chi
|
|
523
|
+
# (ChiHome.problem).
|
|
524
|
+
def argv_problem(argv)
|
|
525
|
+
return "the launch file's ruby #{argv[0]} isn't an absolute path: run chi desktop install --force" unless argv[0].start_with?("/")
|
|
526
|
+
|
|
527
|
+
missing = launch_paths(argv).reject { |path| File.exist?(path) }
|
|
528
|
+
unless missing.empty?
|
|
529
|
+
return "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
|
|
530
|
+
end
|
|
531
|
+
return nil unless home_boot?(argv)
|
|
532
|
+
|
|
533
|
+
home = begin
|
|
534
|
+
ChiHome.read
|
|
535
|
+
rescue ChiHome::Invalid
|
|
536
|
+
nil
|
|
537
|
+
end
|
|
538
|
+
why = home && ChiHome.real(home.ruby) == ChiHome.real(argv[0]) && ChiHome.problem(home)
|
|
539
|
+
why ? "the chi home it boots can't run chi (#{why}): chi home doctor" : nil
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# The argv entries that are paths (absolute): a boot's "-e", its
|
|
543
|
+
# string and "--" are not.
|
|
544
|
+
def launch_paths(argv) = Array(argv).select { |arg| arg.is_a?(String) && arg.start_with?("/") }
|
|
545
|
+
|
|
546
|
+
# An argv in the chi home's boot form ([ruby, "-e", boot, "--"]).
|
|
547
|
+
def home_boot?(argv) = argv.is_a?(Array) && argv[1] == "-e"
|
|
548
|
+
|
|
474
549
|
def write_launch_file(config = launch_config, keep_launch: false)
|
|
475
550
|
if keep_launch
|
|
476
551
|
old = read_launch
|
|
@@ -478,6 +553,13 @@ module Samagotchi
|
|
|
478
553
|
old_env = old["env"]
|
|
479
554
|
old_path = old_env["PATH"] if old_env.is_a?(Hash)
|
|
480
555
|
config["env"]["PATH"] = old_path unless old_path.to_s.empty?
|
|
556
|
+
# A kept [ruby, wrapper] argv keeps the GEM_* its gem was found
|
|
557
|
+
# through; a kept home boot takes none.
|
|
558
|
+
if home_boot?(config["argv"])
|
|
559
|
+
config["env"] = config["env"].except(*GEM_ENV)
|
|
560
|
+
elsif old_env.is_a?(Hash)
|
|
561
|
+
config["env"].merge!(old_env.slice(*GEM_ENV))
|
|
562
|
+
end
|
|
481
563
|
end
|
|
482
564
|
FileUtils.mkdir_p(@support_dir)
|
|
483
565
|
AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -319,6 +319,9 @@ module Samagotchi
|
|
|
319
319
|
# The context sources the peer bundles declare as a peer agent's
|
|
320
320
|
# answers (chi.peer_source); the worker's ContextAbsorber asks it.
|
|
321
321
|
@peer_sources = PeerSources.new
|
|
322
|
+
# The system-prompt text plugins add (chi.prompt_section), run when
|
|
323
|
+
# the prompt is built (#build_system_prompt's sections:).
|
|
324
|
+
@prompt_sections = Plugin::PromptSections.new
|
|
322
325
|
load_plugins if load
|
|
323
326
|
# Registered from what the sources published while they loaded, and
|
|
324
327
|
# with no plugins too: a toolkit's left-out built-ins are reachable
|
|
@@ -527,7 +530,8 @@ module Samagotchi
|
|
|
527
530
|
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
528
531
|
model_notes: -> { model_notes }, memories: memories,
|
|
529
532
|
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files,
|
|
530
|
-
participants: -> { @participants_line }
|
|
533
|
+
participants: -> { @participants_line },
|
|
534
|
+
sections: -> { @prompt_sections ? @prompt_sections.render(@session&.id) : [] })
|
|
531
535
|
end
|
|
532
536
|
private :build_system_prompt
|
|
533
537
|
|
|
@@ -832,6 +836,13 @@ module Samagotchi
|
|
|
832
836
|
@host_registry.model_ref(name).ref
|
|
833
837
|
end
|
|
834
838
|
|
|
839
|
+
# The ref #switch_model! would switch +model_name+ to, checked the way it
|
|
840
|
+
# checks one (UnknownHost, MissingModel), without switching.
|
|
841
|
+
# @return [String]
|
|
842
|
+
def model_switch_ref(model_name)
|
|
843
|
+
model_ref_for(ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries))
|
|
844
|
+
end
|
|
845
|
+
|
|
835
846
|
# Writes the effective model to +session+: the resolved ref, and the
|
|
836
847
|
# name as typed when that differs (model_typed: an alias, for the
|
|
837
848
|
# models: lookup after a resume).
|
|
@@ -1304,7 +1315,7 @@ module Samagotchi
|
|
|
1304
1315
|
# the saved context's status: SessionModelSettings' (resolved on every
|
|
1305
1316
|
# read); these forward.
|
|
1306
1317
|
def saved_context_status(context, model: :current) = @model_settings.saved_context_status(context, model: model)
|
|
1307
|
-
def llm_context_explained = @model_settings.llm_context_explained
|
|
1318
|
+
def llm_context_explained(own: :session) = @model_settings.llm_context_explained(own: own)
|
|
1308
1319
|
def llm_context_summary = @model_settings.llm_context_summary
|
|
1309
1320
|
def llm_context_override = @model_settings.llm_context_override
|
|
1310
1321
|
def chat_model? = @model_settings.chat_model?
|
|
@@ -2827,7 +2838,9 @@ module Samagotchi
|
|
|
2827
2838
|
},
|
|
2828
2839
|
toolkits: @toolkit_binding.plugin_kits,
|
|
2829
2840
|
tool_index: @tool_index,
|
|
2830
|
-
peer_sources: @peer_sources
|
|
2841
|
+
peer_sources: @peer_sources,
|
|
2842
|
+
prompt_sections: @prompt_sections,
|
|
2843
|
+
prompt_changed: -> { prompt_changed! }
|
|
2831
2844
|
)
|
|
2832
2845
|
# What plugins show while they load (an MCP server that didn't
|
|
2833
2846
|
# start) waits for the first turn, beside the load warnings: no UI
|
|
@@ -3144,8 +3157,18 @@ module Samagotchi
|
|
|
3144
3157
|
# The tools changed (a plugin's chi.tools_changed!): the system prompts,
|
|
3145
3158
|
# which declare them, are built again on the next turn.
|
|
3146
3159
|
def tools_changed!
|
|
3160
|
+
prompt_changed!
|
|
3161
|
+
end
|
|
3162
|
+
|
|
3163
|
+
# A plugin's prompt section would give other text now
|
|
3164
|
+
# (chi.prompt_changed!): the system prompts are built again at their
|
|
3165
|
+
# next build (the next turn's start; a running turn keeps the prompt it
|
|
3166
|
+
# started with). One cache restart: the plugin's command asked first
|
|
3167
|
+
# (Plugin::Context#cache_confirm).
|
|
3168
|
+
def prompt_changed!
|
|
3147
3169
|
@prompt_builder&.reset!
|
|
3148
3170
|
end
|
|
3171
|
+
public :prompt_changed!
|
|
3149
3172
|
|
|
3150
3173
|
# What a Plugin::Context reads and calls: the session now, and the
|
|
3151
3174
|
# hook runtime's notify and ask_user.
|
|
@@ -3176,6 +3199,12 @@ module Samagotchi
|
|
|
3176
3199
|
},
|
|
3177
3200
|
model_name: -> { effective_model_ref },
|
|
3178
3201
|
model_key: -> { @model_binding.model_key },
|
|
3202
|
+
# The names a models: map looks the model up under; [] when it doesn't resolve.
|
|
3203
|
+
model_names: lambda do
|
|
3204
|
+
effective_model.names
|
|
3205
|
+
rescue StandardError
|
|
3206
|
+
[]
|
|
3207
|
+
end,
|
|
3179
3208
|
state_dir: -> { session_state_dir },
|
|
3180
3209
|
scratch: -> { @scratch },
|
|
3181
3210
|
setup: -> { SessionSetup.of(@session) },
|
|
@@ -3186,7 +3215,9 @@ module Samagotchi
|
|
|
3186
3215
|
frontend: -> { interface },
|
|
3187
3216
|
cost: -> { @metrics.cost },
|
|
3188
3217
|
usage: -> { @metrics.usage },
|
|
3189
|
-
turn_author: -> { turn_author }
|
|
3218
|
+
turn_author: -> { turn_author },
|
|
3219
|
+
# A plugin's prompt section is in the stable part: a full re-read.
|
|
3220
|
+
cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) }
|
|
3190
3221
|
)
|
|
3191
3222
|
end
|
|
3192
3223
|
|
|
@@ -62,6 +62,8 @@ module Samagotchi
|
|
|
62
62
|
# price_host: the effective model's host, whose hosts.<name>.models
|
|
63
63
|
# prices the cost estimate names. kit_prompt: the next turn's kit
|
|
64
64
|
# prompt text ({kit:, tokens:}), only when its toolkit has one.
|
|
65
|
+
# prompt_sections: the plugins' sections the built prompt holds, only
|
|
66
|
+
# when it holds some.
|
|
65
67
|
# settings: the session's own settings ({"recap.enabled" => false}),
|
|
66
68
|
# only when it has some.
|
|
67
69
|
# @return [Hash] @metrics.snapshot, filled in
|
|
@@ -78,6 +80,9 @@ module Samagotchi
|
|
|
78
80
|
price_host: model.host_name)
|
|
79
81
|
kit_prompt = kit_prompt_stats
|
|
80
82
|
snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
|
|
83
|
+
# The plugins' prompt sections the built prompt holds ({bundle:, name:}).
|
|
84
|
+
sections = prompt_builder.prompt_sections.map(&:to_stats)
|
|
85
|
+
snapshot = snapshot.merge(prompt_sections: sections) unless sections.empty?
|
|
81
86
|
# Each participant's spend under their name (participants.json's).
|
|
82
87
|
if (spend = snapshot[:spend_by_author])
|
|
83
88
|
named = spend.to_h { |author, row| [author, row.merge(name: participants.name_for(author, session: session))] }
|
|
@@ -6,13 +6,15 @@ require "open3"
|
|
|
6
6
|
require "rbconfig"
|
|
7
7
|
require "rubygems/package"
|
|
8
8
|
require "time"
|
|
9
|
+
require_relative "chi_home"
|
|
9
10
|
require_relative "version"
|
|
10
11
|
require_relative "redacted_inspect"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# The gem part of `chi update`: the newest samagotchi on rubygems, and a
|
|
14
|
-
# `gem install` of it through
|
|
15
|
-
# interpreter's bindir, not a shim: the same under
|
|
15
|
+
# `gem install` of it through the chi home's gem command, else this
|
|
16
|
+
# Ruby's own (the real interpreter's bindir, not a shim: the same under
|
|
17
|
+
# mise, rbenv and asdf).
|
|
16
18
|
#
|
|
17
19
|
# SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
|
|
18
20
|
# file the newest version and installs it instead.
|
|
@@ -32,26 +34,31 @@ module Samagotchi
|
|
|
32
34
|
Latest = Struct.new(:version, :source, keyword_init: true)
|
|
33
35
|
Release = Data.define(:version, :created_at, :prerelease)
|
|
34
36
|
|
|
35
|
-
# Runs a command without a shell; [output, success].
|
|
37
|
+
# Runs a command without a shell; [output, success]. +env+ is merged
|
|
38
|
+
# into the inherited one (nil unsets a key).
|
|
36
39
|
class Runner
|
|
37
|
-
def run(argv)
|
|
38
|
-
output, status = Open3.capture2e(*argv)
|
|
40
|
+
def run(argv, env: {})
|
|
41
|
+
output, status = Open3.capture2e(env, *argv)
|
|
39
42
|
[output, status.success?]
|
|
40
43
|
rescue SystemCallError => e
|
|
41
44
|
[e.message, false]
|
|
42
45
|
end
|
|
43
46
|
end
|
|
44
47
|
|
|
45
|
-
#
|
|
46
|
-
# it too).
|
|
47
|
-
def self.gem_bin =
|
|
48
|
+
# The gem command an install runs: the chi home's (ChiHome.gem_bin),
|
|
49
|
+
# else this Ruby's own (chi bootstrap installs commonmarker with it too).
|
|
50
|
+
def self.gem_bin = ChiHome.gem_bin
|
|
48
51
|
|
|
49
52
|
# @param fetcher [#call, nil] → the latest.json body (specs)
|
|
50
|
-
|
|
53
|
+
# @param install_env [Hash] the install child's env on top of this
|
|
54
|
+
# process's (ChiHome.install_env: into the home's gems)
|
|
55
|
+
def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: GemUpdate.gem_bin,
|
|
56
|
+
install_env: ChiHome.install_env)
|
|
51
57
|
@env = env
|
|
52
58
|
@fetcher = fetcher || method(:fetch)
|
|
53
59
|
@runner = runner
|
|
54
60
|
@gem_bin = gem_bin
|
|
61
|
+
@install_env = install_env
|
|
55
62
|
end
|
|
56
63
|
|
|
57
64
|
# @return [Latest]
|
|
@@ -104,7 +111,9 @@ module Samagotchi
|
|
|
104
111
|
|
|
105
112
|
# @return [Array(String, Boolean)] gem's output, success
|
|
106
113
|
def install(latest)
|
|
107
|
-
@runner.run(install_argv(latest))
|
|
114
|
+
return @runner.run(install_argv(latest)) if @install_env.empty?
|
|
115
|
+
|
|
116
|
+
@runner.run(install_argv(latest), env: @install_env)
|
|
108
117
|
end
|
|
109
118
|
|
|
110
119
|
private
|
|
@@ -265,12 +265,14 @@ module Samagotchi
|
|
|
265
265
|
@protected ||= begin
|
|
266
266
|
require_relative "memory_bundle/provenance"
|
|
267
267
|
require_relative "context_sources"
|
|
268
|
+
require_relative "chi_home"
|
|
268
269
|
Guardrails::ProtectedPaths.new(
|
|
269
270
|
store_dir: File.dirname(@approvals.path),
|
|
270
271
|
bundles_dir: MemoryBundle::Provenance.bundles_dir,
|
|
271
272
|
config_path: Samagotchi::ConfigFile.global_path,
|
|
272
273
|
hooks_dir: Hooks::Loader.hooks_dir,
|
|
273
|
-
context_dir: ContextSources.root
|
|
274
|
+
context_dir: ContextSources.root,
|
|
275
|
+
home_path: ChiHome.path
|
|
274
276
|
)
|
|
275
277
|
end
|
|
276
278
|
end
|
|
@@ -79,6 +79,10 @@ module Samagotchi
|
|
|
79
79
|
"approves it"
|
|
80
80
|
GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
|
|
81
81
|
"(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
|
|
82
|
+
ENV_LOADER = "a participant's turn runs mise or direnv: they load the project's env files (a mise.local.toml, " \
|
|
83
|
+
"an .envrc), which may hold the owner's secrets, so the owner approves it"
|
|
84
|
+
MISE_CONFIG = "a participant's turn changes mise's config: the owner's next worker start runs it (its [env] " \
|
|
85
|
+
"can run commands), so the owner approves it"
|
|
82
86
|
GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
|
|
83
87
|
# chi web on loopback asks the owner's token too (Web::App#token_gate,
|
|
84
88
|
# LAN mode); this text check stays as defense in depth: a loopback-only
|
|
@@ -127,7 +131,8 @@ module Samagotchi
|
|
|
127
131
|
ParentApprovals::CHI_TEXT,
|
|
128
132
|
%r{\.local/state/samagotchi|web-grants|grants\.json|web-token},
|
|
129
133
|
%r{(?:\A|[\s/'"=])\.(?:ssh|gnupg|aws|kube|docker)(?![\w.-])},
|
|
130
|
-
%r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)
|
|
134
|
+
%r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|\.envrc|\.?mise(?:\.[\w-]+)?\.local\.toml|(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml|[._]netrc|\.npmrc|\.pypirc|
|
|
135
|
+
\.git-credentials)(?![\w-])}x,
|
|
131
136
|
/\bid_(?:rsa|dsa|ecdsa|ed25519)\b|credentials|\.(?:pem|p12|pfx|tfstate)\b/
|
|
132
137
|
)
|
|
133
138
|
|
|
@@ -187,16 +192,30 @@ module Samagotchi
|
|
|
187
192
|
end
|
|
188
193
|
|
|
189
194
|
# Why the owner is asked (fresh, once): a command that makes a link
|
|
190
|
-
# (LINK),
|
|
195
|
+
# (LINK), one that runs mise or direnv (ENV_LOADER), a write to mise's
|
|
196
|
+
# config (MISE_CONFIG), a write that may land outside the project (OUTSIDE_WRITE, or
|
|
191
197
|
# NO_PROJECT without one); nil when nothing asks.
|
|
192
198
|
def ask_reason(verdict)
|
|
193
199
|
targets = verdict.targets
|
|
194
200
|
return LINK if targets&.command && command(targets, nil).links?
|
|
201
|
+
return ENV_LOADER if targets&.command && command(targets, nil).loads_env?
|
|
202
|
+
return MISE_CONFIG if mise_config_write?(verdict)
|
|
195
203
|
return nil unless outside_write?(verdict)
|
|
196
204
|
|
|
197
205
|
project_root ? OUTSIDE_WRITE : NO_PROJECT
|
|
198
206
|
end
|
|
199
207
|
|
|
208
|
+
# A write or edit of a mise config file (PairCommand.mise_config?), or a
|
|
209
|
+
# command that writes or names one (PairCommand#mise_config_write?).
|
|
210
|
+
def mise_config_write?(verdict)
|
|
211
|
+
targets = verdict.targets
|
|
212
|
+
return false unless targets
|
|
213
|
+
return command(targets, nil).mise_config_write? if targets.command
|
|
214
|
+
return false unless WRITE_TOOLS.include?(verdict.call&.dig(:name).to_s)
|
|
215
|
+
|
|
216
|
+
targets.paths.to_a.compact.any? { |path| PairCommand.mise_config?(File.expand_path(path, targets.cwd || Dir.pwd)) }
|
|
217
|
+
end
|
|
218
|
+
|
|
200
219
|
# A write or edit whose path may land outside the project root, or a
|
|
201
220
|
# command that may write there (PairCommand#outside_writes?); a tmp
|
|
202
221
|
# dir the project isn't in aside (PairCommand.writable?).
|
|
@@ -52,6 +52,9 @@ module Samagotchi
|
|
|
52
52
|
ENV_READERS = %w[printenv security op].freeze
|
|
53
53
|
# Commands that print a stored credential (a token, a password, a
|
|
54
54
|
# key), by the program and the words that say so in its arguments.
|
|
55
|
+
# Programs that load a project's env files (PairBoundary::ENV_LOADER).
|
|
56
|
+
ENV_LOADERS = %w[mise direnv].freeze
|
|
57
|
+
ENV_LOADER_TEXT = %r{(?<![\w./-])(?:mise|direnv)(?![\w./-])}
|
|
55
58
|
CREDENTIAL_PRINTERS = {
|
|
56
59
|
"gh" => [%w[auth token]],
|
|
57
60
|
"git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
|
|
@@ -139,6 +142,20 @@ module Samagotchi
|
|
|
139
142
|
Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
|
|
140
143
|
end
|
|
141
144
|
|
|
145
|
+
# mise's config files (mise.toml, .mise.toml, mise.<env>.toml, .config/mise.toml,
|
|
146
|
+
# anything under .config/mise/, mise/ or .mise/ config*.toml): mise runs
|
|
147
|
+
# their [env] at the next worker start (ProjectEnv).
|
|
148
|
+
MISE_CONFIG_PATH = %r{(?:\A|/)(?:\.?mise(?:\.[^/]+)?\.toml|\.config/mise\.toml|\.config/mise/.+|
|
|
149
|
+
\.?mise/config[^/]*\.toml)\z}x
|
|
150
|
+
MISE_CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml)}
|
|
151
|
+
|
|
152
|
+
# Whether +path+ is one of mise's config files (MISE_CONFIG_PATH), as
|
|
153
|
+
# given or as it resolves.
|
|
154
|
+
def self.mise_config?(path)
|
|
155
|
+
path.to_s.match?(MISE_CONFIG_PATH) ||
|
|
156
|
+
(path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(MISE_CONFIG_PATH) })
|
|
157
|
+
end
|
|
158
|
+
|
|
142
159
|
# Whether +path+ is in a .git/hooks folder, as given or as it resolves
|
|
143
160
|
# (ShellPaths::HOOKS_PART): git runs what is there.
|
|
144
161
|
def self.git_hook?(path)
|
|
@@ -193,6 +210,7 @@ module Samagotchi
|
|
|
193
210
|
|
|
194
211
|
@refusal = read
|
|
195
212
|
rescue StandardError
|
|
213
|
+
@mise_config = true
|
|
196
214
|
@refusal = PairBoundary::UNREADABLE
|
|
197
215
|
end
|
|
198
216
|
|
|
@@ -210,6 +228,28 @@ module Samagotchi
|
|
|
210
228
|
@links = true
|
|
211
229
|
end
|
|
212
230
|
|
|
231
|
+
# Whether it runs mise or direnv (ENV_LOADERS): they load the
|
|
232
|
+
# project's env files (a mise.local.toml, an .envrc), which hold the
|
|
233
|
+
# owner's secrets, into what the command prints; the owner approves
|
|
234
|
+
# it. True for one it can't read.
|
|
235
|
+
def loads_env?
|
|
236
|
+
return @loads_env if defined?(@loads_env)
|
|
237
|
+
|
|
238
|
+
@loads_env = @depth > MAX_DEPTH || ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
|
|
239
|
+
words.is_a?(Array) && env_loader_command?(words)
|
|
240
|
+
end
|
|
241
|
+
rescue StandardError
|
|
242
|
+
@loads_env = true
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Whether it writes (or, not only reading, names) a mise config file
|
|
246
|
+
# (.mise_config?), a script it hands on included: the owner approves
|
|
247
|
+
# it. True for one it can't read.
|
|
248
|
+
def mise_config_write?
|
|
249
|
+
refusal
|
|
250
|
+
@mise_config ||= false
|
|
251
|
+
end
|
|
252
|
+
|
|
213
253
|
# Whether it may write outside the project (asked about, never
|
|
214
254
|
# denied); true for one it can't read.
|
|
215
255
|
def outside_writes?
|
|
@@ -313,8 +353,10 @@ module Samagotchi
|
|
|
313
353
|
program = File.basename(words[at])
|
|
314
354
|
scripts(words, at).each do |script|
|
|
315
355
|
reason = if SHELLS.include?(program) || program == "eval"
|
|
316
|
-
nested(script)
|
|
356
|
+
inner = nested(script)
|
|
357
|
+
inner.refusal.tap { @mise_config = true if inner.mise_config_write? }
|
|
317
358
|
elsif INTERPRETERS.include?(program)
|
|
359
|
+
@mise_config = true if script.match?(MISE_CONFIG_TEXT)
|
|
318
360
|
script_text_refusal(script)
|
|
319
361
|
end
|
|
320
362
|
return reason if reason
|
|
@@ -448,6 +490,8 @@ module Samagotchi
|
|
|
448
490
|
resolved.each do |word, path|
|
|
449
491
|
return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
|
|
450
492
|
|
|
493
|
+
@mise_config = true if !@read_only && PairCommand.mise_config?(path || word)
|
|
494
|
+
|
|
451
495
|
if path
|
|
452
496
|
return PairBoundary::SECRET if @paths.secret_refusal([path])
|
|
453
497
|
return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
|
|
@@ -512,6 +556,18 @@ module Samagotchi
|
|
|
512
556
|
end
|
|
513
557
|
end
|
|
514
558
|
|
|
559
|
+
def env_loader_command?(words)
|
|
560
|
+
at = program_at(words)
|
|
561
|
+
return true if at && ENV_LOADERS.include?(File.basename(words[at]))
|
|
562
|
+
return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(File.basename(word)) }
|
|
563
|
+
|
|
564
|
+
runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
|
|
565
|
+
name = File.basename(words[runner])
|
|
566
|
+
scripts(words, runner).any? do |script|
|
|
567
|
+
SHELLS.include?(name) || name == "eval" ? nested(script).loads_env? : script.match?(ENV_LOADER_TEXT)
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
515
571
|
def nested(script) = PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root,
|
|
516
572
|
depth: @depth + 1, web: @web)
|
|
517
573
|
|
|
@@ -14,7 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# hooks, rules, manifests) and attached context (a source's
|
|
15
15
|
# command runs later, outside the gate; chi context writes
|
|
16
16
|
# it); nothing legitimate writes there through the file tools.
|
|
17
|
-
# ask (once / session): config.yml
|
|
17
|
+
# ask (once / session): config.yml, home.json (the chi home: the
|
|
18
|
+
# Ruby every chi launch boots) and the plain hooks dir. The
|
|
18
19
|
# system bundle's config protocol edits config.yml with
|
|
19
20
|
# write/edit, so this keeps the user in the loop without
|
|
20
21
|
# breaking it.
|
|
@@ -29,7 +30,8 @@ module Samagotchi
|
|
|
29
30
|
# @param config_path [String] config.yml
|
|
30
31
|
# @param hooks_dir [String] the plain hooks dir
|
|
31
32
|
# @param context_dir [String, nil] <state dir>/context (ContextSources.root)
|
|
32
|
-
|
|
33
|
+
# @param home_path [String, nil] home.json (ChiHome.path)
|
|
34
|
+
def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil, home_path: nil)
|
|
33
35
|
@deny = [
|
|
34
36
|
["guardrail-store", store_dir, "the approval store belongs to the user"],
|
|
35
37
|
["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"],
|
|
@@ -37,8 +39,9 @@ module Samagotchi
|
|
|
37
39
|
]
|
|
38
40
|
@ask = [
|
|
39
41
|
["chi-config", config_path, "changes chi's config.yml"],
|
|
42
|
+
["chi-home", home_path, "changes the Ruby every chi launch boots"],
|
|
40
43
|
["chi-hooks", hooks_dir, "changes chi's hooks"]
|
|
41
|
-
]
|
|
44
|
+
].select { |_, root, _| root }
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
# Vote on +verdict+ (its targets).
|
|
@@ -30,13 +30,16 @@ module Samagotchi
|
|
|
30
30
|
.netrc .npmrc .pypirc .claude.json .zsh_history .bash_history .history .python_history
|
|
31
31
|
.node_repl_history .psql_history .mysql_history .irb_history .lesshst
|
|
32
32
|
.local/share/fish/fish_history].freeze
|
|
33
|
-
# Project files that hold secrets, by a path part's name: .env
|
|
34
|
-
# netrc, keys and certificates, anything named credentials, package
|
|
33
|
+
# Project files that hold secrets, by a path part's name: .env* (.envrc
|
|
34
|
+
# too), mise's local files (mise.local.toml, .mise.dev.local.toml), netrc, keys and certificates, anything named credentials, package
|
|
35
35
|
# registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
|
|
36
36
|
# secrets file; and a git config (.git/config, a submodule's
|
|
37
37
|
# .git/modules/x/config), checked apart.
|
|
38
|
-
SECRET_FILE = /\A(?:\.env
|
|
38
|
+
SECRET_FILE = /\A(?:\.env.*|\.?mise(?:\.[\w-]+)?\.local\.toml|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
|
|
39
39
|
\.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
|
|
40
|
+
# mise's local config in its config dirs (.config/mise/config.local.toml,
|
|
41
|
+
# config.<env>.local.toml; mise/ and .mise/ too): by the path's end.
|
|
42
|
+
SECRET_PATH = %r{(?:\A|/)(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml\z}i
|
|
40
43
|
# The .docker/config.json kind: a dot dir's config holding tokens.
|
|
41
44
|
SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
|
|
42
45
|
GIT_DIR = ".git"
|
|
@@ -124,7 +127,7 @@ module Samagotchi
|
|
|
124
127
|
return true if denied_roots.any? { |root| PathResolve.under?(path, root) }
|
|
125
128
|
|
|
126
129
|
parts = path.split(File::SEPARATOR)
|
|
127
|
-
return true if parts.any? { |part| SECRET_FILE.match?(part) }
|
|
130
|
+
return true if parts.any? { |part| SECRET_FILE.match?(part) } || path.match?(SECRET_PATH)
|
|
128
131
|
return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
|
|
129
132
|
|
|
130
133
|
git_config?(parts)
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
# ParentPolicy's user_only); System 1 may, only if the user lists the
|
|
24
24
|
# rule by name in guardrails.system_one.rules, and never for the
|
|
25
25
|
# chi-answer/chi-broadcast ones (SystemOne::SelfProtect::NEVER_AUTO).
|
|
26
|
-
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
|
|
26
|
+
PROTECTED_RULES = %w[chi-config chi-home chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
|
|
27
27
|
model-overlay-write chi-answer-piped chi-answer-http].freeze
|
|
28
28
|
SCOPES = %w[once session repo rule].freeze
|
|
29
29
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|