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
|
@@ -107,7 +107,7 @@ class Plugin
|
|
|
107
107
|
@next_id = 0
|
|
108
108
|
@dead = nil
|
|
109
109
|
@closing = false
|
|
110
|
-
@stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v
|
|
110
|
+
@stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v&.to_s] }, *command,
|
|
111
111
|
chdir: cwd, pgroup: true)
|
|
112
112
|
@pid = @wait.pid
|
|
113
113
|
start_watchdog
|
|
@@ -433,7 +433,7 @@ class Plugin
|
|
|
433
433
|
def spawn(server, ctx)
|
|
434
434
|
cancelled = -> { ctx.cancelled? }
|
|
435
435
|
client = server.client = Client.new(
|
|
436
|
-
server.command, env: server
|
|
436
|
+
server.command, env: server_env(server), cwd: server.cwd,
|
|
437
437
|
log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
|
|
438
438
|
on_exit: ->(reason) { exited(server, reason, ctx) },
|
|
439
439
|
on_notification: lambda { |method, _params|
|
|
@@ -454,6 +454,13 @@ class Plugin
|
|
|
454
454
|
client
|
|
455
455
|
end
|
|
456
456
|
|
|
457
|
+
# A server's env: the session's project env (the worker's mise env, on a
|
|
458
|
+
# chi that has it: Samagotchi::ProjectEnv) under the server's own keys.
|
|
459
|
+
def server_env(server)
|
|
460
|
+
project = defined?(Samagotchi::ProjectEnv) ? Samagotchi::ProjectEnv.current.env : {}
|
|
461
|
+
project.merge(server.env)
|
|
462
|
+
end
|
|
463
|
+
|
|
457
464
|
# The first sentence of initialize's instructions (at most
|
|
458
465
|
# DESCRIPTION_CHARS), nil when there are none.
|
|
459
466
|
def info_of(result)
|
|
@@ -494,7 +501,7 @@ class Plugin
|
|
|
494
501
|
return nil unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
495
502
|
|
|
496
503
|
cancelled = -> { ctx.cancelled? }
|
|
497
|
-
client = Client.new(server.command, env: server
|
|
504
|
+
client = Client.new(server.command, env: server_env(server), cwd: server.cwd,
|
|
498
505
|
log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) })
|
|
499
506
|
begin
|
|
500
507
|
initialized = client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: model-notes
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.2.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Reviewed model notes: working habits a model family needs, loaded into the prompt only for the models each note's models: line names"
|
|
6
6
|
trust_level: reviewed
|
|
@@ -9,3 +9,6 @@ files:
|
|
|
9
9
|
model_notes_deepseek.md:
|
|
10
10
|
sha256: sha256:0b07e40edc1a3d4a3ad6bea08d9b338913f78cab19a5809bcf4593f459f9bc5e
|
|
11
11
|
description: "DeepSeek V4 working habits: explore briefly then act, commit per item, stop digging, memory_read for memories, file tools over heredocs"
|
|
12
|
+
model_notes_tone.md:
|
|
13
|
+
sha256: sha256:e47a93dc3b3cb78a9bd3eb7f04ef1c4d2a30b4a7c0110a66e1e3a26e363827ca
|
|
14
|
+
description: "Concise answers for every model (models: *): answer first, no preambles or closing offers"
|
|
@@ -35,3 +35,17 @@ bundle itself.
|
|
|
35
35
|
The dates and tasks differ, so this is a hint, not an A/B: an A/B is the same kind of task with the note on and
|
|
36
36
|
muted (`chi send --new --mute model_notes_deepseek`). The shipped text is shorter than the one measured (795
|
|
37
37
|
characters against 1,519) and keeps its habits.
|
|
38
|
+
|
|
39
|
+
## model_notes_tone
|
|
40
|
+
|
|
41
|
+
- **models:** `*`: every model. It stacks on a family's note (`spec/model_ids.yml` `expected_overlaps`).
|
|
42
|
+
- **Origin, 2026-10-29:** chi's prompt has no style guidance, so each model talks in its own voice and answers run
|
|
43
|
+
long. A `/tone` bundle (presets, a session command) was built and parked; the user chose the lighter way, a model
|
|
44
|
+
note, with the gentlest text: only the anti-padding part of the concise preset ("leave out what went as expected"
|
|
45
|
+
was dropped: it can hide useful confirmations).
|
|
46
|
+
- **Approved:** the text as shipped, by the user, 2026-10-29 (after the check below); ships in model-notes (the `dev`
|
|
47
|
+
profile) first, `core` later only after more evidence (other model families, a week of the user's own use).
|
|
48
|
+
- **Evidence:** a with/without check on the local Qwen3.8 Flash Next (Splash), 5 tasks (4 questions about this repo,
|
|
49
|
+
1 small code edit), one run each, `chi --non-interactive` with isolated state: answers 12.5k → 10.1k characters
|
|
50
|
+
(−19%), 4 of 5 shorter; openings more direct, closing summaries gone; nothing a task needed was lost (dropped: one
|
|
51
|
+
worked glob example, one aside about the model's own fixed mistake). N=1 per task: a direction, not a measurement.
|
|
@@ -12,6 +12,9 @@ module Samagotchi
|
|
|
12
12
|
# The costs worth asking about (Thinking.cache_cost,
|
|
13
13
|
# ReasoningHistory.cache_cost); :tail and nil are not.
|
|
14
14
|
COSTLY = %i[full provider history].freeze
|
|
15
|
+
# Thread.current key: whether the plugin command running on this
|
|
16
|
+
# thread may ask (#asking).
|
|
17
|
+
ASKING = :samagotchi_cache_confirm_asking
|
|
15
18
|
|
|
16
19
|
# The line that applies a change, and the question the reply ends with.
|
|
17
20
|
Confirm = Data.define(:line, :text) do
|
|
@@ -42,6 +45,21 @@ module Samagotchi
|
|
|
42
45
|
changed && history && COSTLY.include?(cost)
|
|
43
46
|
end
|
|
44
47
|
|
|
48
|
+
# Run +block+ as a plugin command whose sender can answer a confirm
|
|
49
|
+
# (+flag+: SessionCommands#run's confirm, false once the line has
|
|
50
|
+
# --yes): Plugin::Context#cache_confirm asks only inside it, on this
|
|
51
|
+
# thread. Nests; threads the block starts don't inherit it.
|
|
52
|
+
def asking(flag)
|
|
53
|
+
previous = Thread.current[ASKING]
|
|
54
|
+
Thread.current[ASKING] = flag ? true : false
|
|
55
|
+
yield
|
|
56
|
+
ensure
|
|
57
|
+
Thread.current[ASKING] = previous
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether the plugin command on this thread may ask (#asking).
|
|
61
|
+
def asking? = Thread.current[ASKING] == true
|
|
62
|
+
|
|
45
63
|
# Whether a cache-changing command should ask, and its reply line.
|
|
46
64
|
# Kept independent of any UI so commands and plugins can share it.
|
|
47
65
|
# @param line [String] the full command line without --yes
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "time"
|
|
8
|
+
require_relative "atomic_file"
|
|
9
|
+
require_relative "installed_gem"
|
|
10
|
+
require_relative "log"
|
|
11
|
+
require_relative "paths"
|
|
12
|
+
require_relative "version"
|
|
13
|
+
|
|
14
|
+
module Samagotchi
|
|
15
|
+
# The chi home: the one Ruby (and its gem path) every chi launch boots,
|
|
16
|
+
# whoever launches it (a terminal on another Ruby, chi web, the macOS
|
|
17
|
+
# helper under launchd). Recorded in home.json under the config dir
|
|
18
|
+
# ($XDG_CONFIG_HOME/samagotchi/home.json) by `chi home set`, or once, the
|
|
19
|
+
# first time, by chi bootstrap or chi update (#record_first!); never
|
|
20
|
+
# overwritten implicitly.
|
|
21
|
+
#
|
|
22
|
+
# One mechanism (docs/architecture.md, "How chi launches itself"):
|
|
23
|
+
# Record#boot_argv puts `Gem.paths = {…}` at the front of a `ruby -e`
|
|
24
|
+
# boot string, so no GEM_HOME/GEM_PATH ever goes into an environment
|
|
25
|
+
# (the tools a worker runs keep the spawner's, or the project's). Every
|
|
26
|
+
# launch site asks #current at launch time and keeps its own argv when
|
|
27
|
+
# there is no usable home.
|
|
28
|
+
module ChiHome
|
|
29
|
+
FILE = "home.json"
|
|
30
|
+
# What a launch outside a worker boots: the newest chi in the home,
|
|
31
|
+
# run as its own `chi` executable (ARGV after the "--").
|
|
32
|
+
CHI_BOOT = "gem 'samagotchi', '>= 0.a'; load Gem.bin_path('samagotchi', 'chi')"
|
|
33
|
+
# Where `mise` usually is when PATH doesn't have it (launchd's bare PATH).
|
|
34
|
+
MISE_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", File.join(Dir.home, ".local", "bin")].freeze
|
|
35
|
+
# What a probe of another Ruby must not inherit: they would move it off
|
|
36
|
+
# its own gems.
|
|
37
|
+
PROBE_UNSET = %w[GEM_HOME GEM_PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH].freeze
|
|
38
|
+
PROBE = "require 'json'; print JSON.generate('ruby' => RbConfig.ruby, 'gem_home' => Gem.dir, " \
|
|
39
|
+
"'gem_path' => Gem.path.join(File::PATH_SEPARATOR))"
|
|
40
|
+
GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
|
|
41
|
+
|
|
42
|
+
class Invalid < StandardError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# home.json's fields. +gem_path+ is what `Gem.paths=` takes (dirs joined
|
|
46
|
+
# by File::PATH_SEPARATOR); +mise+ an absolute path or nil.
|
|
47
|
+
Record = Data.define(:ruby, :gem_home, :gem_path, :mise, :set_at, :set_by_version) do
|
|
48
|
+
# home.json's (or a probe's) fields, checked: ruby, gem_home and every
|
|
49
|
+
# gem_path entry absolute paths, mise an absolute path or nil.
|
|
50
|
+
# @raise [Invalid]
|
|
51
|
+
def self.from_h(hash)
|
|
52
|
+
fields = members.to_h { |key| [key, hash[key.to_s]] }
|
|
53
|
+
%i[ruby gem_home gem_path].each do |key|
|
|
54
|
+
value = fields[key]
|
|
55
|
+
raise Invalid, "#{key} is missing" unless value.is_a?(String) && !value.empty?
|
|
56
|
+
end
|
|
57
|
+
raise Invalid, "ruby is not an absolute path" unless fields[:ruby].start_with?("/")
|
|
58
|
+
raise Invalid, "gem_home is not an absolute path" unless fields[:gem_home].start_with?("/")
|
|
59
|
+
|
|
60
|
+
dirs = fields[:gem_path].split(File::PATH_SEPARATOR)
|
|
61
|
+
raise Invalid, "gem_path has a relative entry" unless dirs.all? { |dir| dir.start_with?("/") }
|
|
62
|
+
|
|
63
|
+
mise = fields[:mise]
|
|
64
|
+
raise Invalid, "mise is not an absolute path" unless mise.nil? || (mise.is_a?(String) && mise.start_with?("/"))
|
|
65
|
+
|
|
66
|
+
new(**fields)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h = members.to_h { |key| [key.to_s, public_send(key)] }
|
|
70
|
+
def gem_dirs = gem_path.split(File::PATH_SEPARATOR).reject(&:empty?)
|
|
71
|
+
def spec_dirs = gem_dirs.map { |dir| File.join(dir, "specifications") }
|
|
72
|
+
# The home Ruby's own gem command (its bindir, not a shim).
|
|
73
|
+
def gem_bin = File.join(File.dirname(ruby), "gem")
|
|
74
|
+
def ruby_ok? = File.file?(ruby) && File.executable?(ruby)
|
|
75
|
+
|
|
76
|
+
# The samagotchi versions installed in the home, newest last.
|
|
77
|
+
# @return [Array<Gem::Version>]
|
|
78
|
+
def versions
|
|
79
|
+
spec_dirs.flat_map do |dir|
|
|
80
|
+
Dir.children(dir).filter_map { |name| GEMSPEC.match(name)&.[](1) }
|
|
81
|
+
rescue SystemCallError
|
|
82
|
+
[]
|
|
83
|
+
end.select { |v| Gem::Version.correct?(v) }.map { |v| Gem::Version.new(v) }.uniq.sort
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def newest_version = versions.last
|
|
87
|
+
|
|
88
|
+
# Whether a gem named +name+ is installed in the home.
|
|
89
|
+
def gem?(name)
|
|
90
|
+
spec_dirs.any? { |dir| !Dir.glob(File.join(dir, "#{name}-*.gemspec")).empty? }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The `Gem.paths =` line every boot starts with: string literals only
|
|
94
|
+
# (String#inspect escapes `#{`), no env read.
|
|
95
|
+
def prelude = "Gem.paths = {'GEM_HOME' => #{gem_home.inspect}, 'GEM_PATH' => #{gem_path.inspect}}"
|
|
96
|
+
|
|
97
|
+
# [ruby, "-e", "<prelude>; <run>"]: a site appends "--" and its args
|
|
98
|
+
# when +run+ runs chi's executable (CHI_BOOT).
|
|
99
|
+
def boot_argv(run:) = [ruby, "-e", "#{prelude}; #{run}"]
|
|
100
|
+
|
|
101
|
+
# Whether this process runs on the home: its Ruby and its gem dir.
|
|
102
|
+
def running_here?(ruby: RbConfig.ruby, gem_dir: Gem.dir)
|
|
103
|
+
ChiHome.real(ruby) == ChiHome.real(self.ruby) && ChiHome.real(gem_dir) == ChiHome.real(gem_home)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@warned = {}
|
|
108
|
+
@warn_mutex = Mutex.new
|
|
109
|
+
|
|
110
|
+
module_function
|
|
111
|
+
|
|
112
|
+
# @return [String] $XDG_CONFIG_HOME/samagotchi/home.json
|
|
113
|
+
def path(env: ENV) = File.join(Paths.config_home(env: env), Paths::APP_DIR, FILE)
|
|
114
|
+
|
|
115
|
+
# The record as home.json has it, usable or not (chi home shows it);
|
|
116
|
+
# nil without one.
|
|
117
|
+
# @raise [Invalid] a file that isn't home.json's shape
|
|
118
|
+
def read(path: self.path)
|
|
119
|
+
return nil unless File.file?(path)
|
|
120
|
+
|
|
121
|
+
data = JSON.parse(File.read(path))
|
|
122
|
+
raise Invalid, "#{Paths.tilde(path)} is not a JSON object" unless data.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
Record.from_h(data)
|
|
125
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
126
|
+
raise Invalid, "#{Paths.tilde(path)}: #{e.message.lines.first.to_s.strip}"
|
|
127
|
+
rescue Invalid => e
|
|
128
|
+
raise if e.message.start_with?(Paths.tilde(path))
|
|
129
|
+
|
|
130
|
+
raise Invalid, "#{Paths.tilde(path)}: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The home every launch boots, or nil, where each site keeps its own
|
|
134
|
+
# argv: no home.json; one that isn't its shape (warned once); its ruby
|
|
135
|
+
# gone (a Ruby upgrade); no samagotchi in its gem path; or this chi
|
|
136
|
+
# runs from a source checkout (a worktree's bin/chi spawns its own code).
|
|
137
|
+
# @param gem_spec [Gem::Specification, nil] the installed gem this runs from
|
|
138
|
+
# @return [Record, nil]
|
|
139
|
+
def current(path: self.path, gem_spec: InstalledGem.spec)
|
|
140
|
+
return nil unless gem_spec
|
|
141
|
+
|
|
142
|
+
home = read(path: path)
|
|
143
|
+
return nil unless home
|
|
144
|
+
|
|
145
|
+
why = problem(home)
|
|
146
|
+
why ? warn_once(:unusable, path: path, problem: why) : home
|
|
147
|
+
rescue Invalid => e
|
|
148
|
+
warn_once(:invalid, path: path, error: e.message)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Why a recorded home can't boot chi, or nil.
|
|
152
|
+
# @return [String, nil]
|
|
153
|
+
def problem(home)
|
|
154
|
+
return "home ruby missing: #{home.ruby}" unless home.ruby_ok?
|
|
155
|
+
return "no samagotchi in the home's gems (#{home.gem_path})" if home.versions.empty?
|
|
156
|
+
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The home's boot, or nil without a usable home (#current).
|
|
161
|
+
# @return [Array<String>, nil]
|
|
162
|
+
def boot_argv(run:, home: current) = home&.boot_argv(run: run)
|
|
163
|
+
|
|
164
|
+
# Where a gem install lands: the home's gem dir, else this process's
|
|
165
|
+
# (UpdateCheck's writability).
|
|
166
|
+
def gem_dir(gem_spec: InstalledGem.spec) = current(gem_spec: gem_spec)&.gem_home || Gem.dir
|
|
167
|
+
|
|
168
|
+
# The specification dirs new workers activate chi from: the home's,
|
|
169
|
+
# else this process's (InstalledVersions' "installed; restart").
|
|
170
|
+
def spec_dirs(gem_spec: InstalledGem.spec) = current(gem_spec: gem_spec)&.spec_dirs || Gem::Specification.dirs
|
|
171
|
+
|
|
172
|
+
# The gem command a gem install runs: the home's, else this Ruby's own.
|
|
173
|
+
def gem_bin(home = current) = home ? home.gem_bin : File.join(RbConfig::CONFIG["bindir"], "gem")
|
|
174
|
+
|
|
175
|
+
# The env for a gem install child (chi update, chi bootstrap's
|
|
176
|
+
# commonmarker): into the home's gems whatever the spawner's
|
|
177
|
+
# GEM_HOME/GEM_PATH say (a chruby shell, an old helper's launch file),
|
|
178
|
+
# and with mise's dir on PATH (mise's rubygems plugin runs `mise
|
|
179
|
+
# reshim` after an install). {} without a home: the env as today.
|
|
180
|
+
# @return [Hash{String=>String}]
|
|
181
|
+
def install_env(home = current, env: ENV)
|
|
182
|
+
return {} unless home
|
|
183
|
+
|
|
184
|
+
result = { "GEM_HOME" => home.gem_home, "GEM_PATH" => home.gem_path }
|
|
185
|
+
if home.mise
|
|
186
|
+
dirs = env["PATH"].to_s.split(File::PATH_SEPARATOR)
|
|
187
|
+
mise_dir = File.dirname(home.mise)
|
|
188
|
+
result["PATH"] = (dirs + [mise_dir]).uniq.join(File::PATH_SEPARATOR) unless dirs.include?(mise_dir)
|
|
189
|
+
end
|
|
190
|
+
result
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The fallback pin for a worker's `>= 0.a` (a broken newest install):
|
|
194
|
+
# the spawner's own version when the home has it, else the home's
|
|
195
|
+
# newest.
|
|
196
|
+
# @return [String, nil]
|
|
197
|
+
def fallback_version(home, gem_spec)
|
|
198
|
+
versions = home.versions
|
|
199
|
+
own = gem_spec && Gem::Version.new(gem_spec.version.to_s)
|
|
200
|
+
(own && versions.include?(own) ? own : versions.last)&.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# This process's Ruby and gem path as a record (chi home set without
|
|
204
|
+
# --ruby, a first record from the running chi).
|
|
205
|
+
def from_running(env: ENV)
|
|
206
|
+
Record.new(ruby: real(RbConfig.ruby), gem_home: Gem.dir, gem_path: Gem.path.join(File::PATH_SEPARATOR),
|
|
207
|
+
mise: find_mise(env: env), set_at: Time.now.utc.iso8601, set_by_version: VERSION)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Another Ruby's own gem dirs, asked of it (its GEM_*/RUBYOPT unset
|
|
211
|
+
# unless +gem_env+ gives the GEM_HOME/GEM_PATH its chi lived under).
|
|
212
|
+
# @param runner [#call] (env, argv) → [output, success]
|
|
213
|
+
# @return [Record, nil] nil when it doesn't run or answer
|
|
214
|
+
def probe(ruby, gem_env: {}, env: ENV, runner: method(:capture))
|
|
215
|
+
child_env = PROBE_UNSET.to_h { |key| [key, nil] }.merge(gem_env.slice("GEM_HOME", "GEM_PATH").compact)
|
|
216
|
+
output, ok = runner.call(child_env, [ruby, "-e", PROBE])
|
|
217
|
+
return nil unless ok
|
|
218
|
+
|
|
219
|
+
data = JSON.parse(output.to_s.lines.last.to_s)
|
|
220
|
+
return nil unless data.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
Record.from_h(data.merge("ruby" => data["ruby"].is_a?(String) ? real(data["ruby"]) : data["ruby"],
|
|
223
|
+
"mise" => find_mise(env: env), "set_at" => Time.now.utc.iso8601,
|
|
224
|
+
"set_by_version" => VERSION))
|
|
225
|
+
rescue JSON::ParserError, TypeError, Invalid
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @raise [SystemCallError]
|
|
230
|
+
def write!(home, path: self.path)
|
|
231
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
232
|
+
AtomicFile.write(path, "#{JSON.pretty_generate(home.to_h)}\n", perm: 0o644)
|
|
233
|
+
home
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The first record, once, from an installed chi (chi bootstrap, chi
|
|
237
|
+
# update): of the macOS helper's ruby (when its launch file names one
|
|
238
|
+
# whose newest chi is at least this one: the Ruby it already runs) and
|
|
239
|
+
# this chi's, the one with the newest chi; the helper's on a tie.
|
|
240
|
+
# Never over an existing home.json, never from a checkout or under
|
|
241
|
+
# Bundler, never a Ruby without samagotchi.
|
|
242
|
+
# @param launch_path [String, nil] the helper's launch.json
|
|
243
|
+
# @param bundler [Boolean, nil] running under Bundler (nil: found out)
|
|
244
|
+
# @return [Record, nil] the home written, nil when none was
|
|
245
|
+
def record_first!(gem_spec: InstalledGem.spec, launch_path: nil, path: self.path, env: ENV,
|
|
246
|
+
runner: method(:capture), bundler: nil)
|
|
247
|
+
return nil unless gem_spec
|
|
248
|
+
return nil if bundler.nil? ? under_bundler?(env) : bundler
|
|
249
|
+
return nil if File.exist?(path)
|
|
250
|
+
|
|
251
|
+
own = Gem::Version.new(VERSION)
|
|
252
|
+
helper = helper_home(launch_path, env: env, runner: runner)
|
|
253
|
+
helper = nil if helper && (helper.newest_version.nil? || helper.newest_version < own)
|
|
254
|
+
home = [helper, from_running(env: env)].compact.select { |candidate| problem(candidate).nil? }
|
|
255
|
+
.max_by(&:newest_version)
|
|
256
|
+
home && write!(home, path: path)
|
|
257
|
+
rescue SystemCallError => e
|
|
258
|
+
Log.warn(:config, "home_record_failed", error: e.class.name, msg: e.message)
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The helper's launch.json ruby (argv[0]) probed with the GEM_* it was
|
|
263
|
+
# installed with; nil without one.
|
|
264
|
+
def helper_home(launch_path, env:, runner:)
|
|
265
|
+
return nil unless launch_path && File.file?(launch_path)
|
|
266
|
+
|
|
267
|
+
launch = JSON.parse(File.read(launch_path))
|
|
268
|
+
ruby = Array(launch["argv"]).first if launch.is_a?(Hash)
|
|
269
|
+
return nil unless ruby.is_a?(String) && ruby.start_with?("/") && File.executable?(ruby)
|
|
270
|
+
|
|
271
|
+
gem_env = launch["env"].is_a?(Hash) ? launch["env"] : {}
|
|
272
|
+
probe(ruby, gem_env: gem_env, env: env, runner: runner)
|
|
273
|
+
rescue JSON::ParserError, SystemCallError
|
|
274
|
+
nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The test UpdateCommand's @bundler makes: a first record from a
|
|
278
|
+
# `bundle exec` would name the bundle's gems.
|
|
279
|
+
def under_bundler?(env = ENV) = !!(defined?(::Bundler) || !env["BUNDLE_GEMFILE"].to_s.empty?)
|
|
280
|
+
|
|
281
|
+
# mise's absolute path: on +env+'s PATH, else in MISE_DIRS; nil without.
|
|
282
|
+
def find_mise(env: ENV)
|
|
283
|
+
dirs = env["PATH"].to_s.split(File::PATH_SEPARATOR).select { |dir| dir.start_with?("/") } + MISE_DIRS
|
|
284
|
+
dirs.map { |dir| File.join(dir, "mise") }.find { |file| File.file?(file) && File.executable?(file) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def real(path)
|
|
288
|
+
File.realpath(path)
|
|
289
|
+
rescue SystemCallError, TypeError
|
|
290
|
+
path.to_s
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def capture(env, argv)
|
|
294
|
+
output, status = Open3.capture2e(env, *argv)
|
|
295
|
+
[output, status.success?]
|
|
296
|
+
rescue SystemCallError => e
|
|
297
|
+
[e.message, false]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Logs a home that can't be used, once per process and reason; nil.
|
|
301
|
+
def warn_once(event, **fields)
|
|
302
|
+
key = [event, fields]
|
|
303
|
+
first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
|
|
304
|
+
Log.warn(:config, "home_#{event}", **fields) if first
|
|
305
|
+
nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Specs: forget what was warned.
|
|
309
|
+
def reset_warnings! = @warn_mutex.synchronize { @warned.clear }
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require_relative "atomic_file"
|
|
6
|
+
require_relative "chi_home"
|
|
7
|
+
require_relative "log"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# The `chi` a worker's tools run: $XDG_STATE_HOME/samagotchi/bin/chi, a
|
|
12
|
+
# shell script that execs the chi home's Ruby with its boot
|
|
13
|
+
# (ChiHome::Record#boot_argv, CHI_BOOT). ProjectEnv puts its dir first on
|
|
14
|
+
# the worker's PATH, so after the project env (whose PATH would find the
|
|
15
|
+
# project Ruby's chi, an old one or none) an agent's `chi send`, `chi self`
|
|
16
|
+
# or `chi answer` still runs the home's chi. The dir holds `chi` only: the
|
|
17
|
+
# project's ruby, gem and bundle stay the project's; bin/chi, a checkout
|
|
18
|
+
# path or `bundle exec chi` stay themselves.
|
|
19
|
+
module ChiShim
|
|
20
|
+
NAME = "chi"
|
|
21
|
+
HEADER = "# chi's own chi for its tools (ChiShim): rewritten at each worker start from the chi home.\n"
|
|
22
|
+
# A project's RUBYOPT/RUBYLIB/RUBYGEMS_GEMDEPS/GEMRC/BUNDLE_* (a `bundle
|
|
23
|
+
# exec` the tool runs under, a mise [env]) would load the project's gems
|
|
24
|
+
# or settings into the home's chi, its LD_*/DYLD_* libraries into its
|
|
25
|
+
# ruby: unset before the exec.
|
|
26
|
+
UNSET = "unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS GEMRC LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES " \
|
|
27
|
+
"DYLD_LIBRARY_PATH\n" \
|
|
28
|
+
"for v in $(/usr/bin/env | /usr/bin/sed -n 's/^\\(BUNDLE[A-Za-z0-9_]*\\)=.*/\\1/p'); do unset \"$v\"; done\n"
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @return [String] $XDG_STATE_HOME/samagotchi/bin
|
|
33
|
+
def dir(env: ENV) = File.join(Paths.state_dir(env: env), "bin")
|
|
34
|
+
|
|
35
|
+
def path(dir: self.dir) = File.join(dir, NAME)
|
|
36
|
+
|
|
37
|
+
# The script for +home+.
|
|
38
|
+
# @param home [ChiHome::Record]
|
|
39
|
+
def content(home)
|
|
40
|
+
argv = home.boot_argv(run: ChiHome::CHI_BOOT) + ["--"]
|
|
41
|
+
"#!/bin/sh\n#{HEADER}#{UNSET}exec #{argv.map { |arg| Shellwords.escape(arg) }.join(" ")} \"$@\"\n"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Writes the shim for +home+ unless it is already that (atomically: a
|
|
45
|
+
# tool running `chi` meanwhile gets the old one or the new one).
|
|
46
|
+
# @return [Boolean] whether the shim is there and current
|
|
47
|
+
def ensure!(home, dir: self.dir)
|
|
48
|
+
file = path(dir: dir)
|
|
49
|
+
want = content(home)
|
|
50
|
+
return true if current?(file, want)
|
|
51
|
+
|
|
52
|
+
FileUtils.mkdir_p(dir)
|
|
53
|
+
AtomicFile.write(file, want, perm: 0o755)
|
|
54
|
+
true
|
|
55
|
+
rescue SystemCallError => e
|
|
56
|
+
Log.warn(:worker, "chi_shim_failed", path: file, error: e.class.name, msg: e.message)
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Why the shim isn't +home+'s (chi home doctor), or nil: nil too
|
|
61
|
+
# without one (the first worker start writes it).
|
|
62
|
+
# @return [String, nil]
|
|
63
|
+
def problem(home, dir: self.dir)
|
|
64
|
+
file = path(dir: dir)
|
|
65
|
+
return nil if !File.exist?(file) || current?(file, content(home))
|
|
66
|
+
|
|
67
|
+
"the chi script tools run (#{Paths.tilde(file)}) boots another chi than the home: " \
|
|
68
|
+
"the next worker start rewrites it (chi sessions restart ID for a running session)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def current?(file, want)
|
|
72
|
+
File.file?(file) && File.executable?(file) && File.read(file) == want
|
|
73
|
+
rescue SystemCallError
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/samagotchi/child_env.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "installed_gem"
|
|
4
4
|
require_relative "installed_versions"
|
|
5
|
+
require_relative "project_env"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# What a chi process another chi starts must not take from its spawner's
|
|
@@ -23,11 +24,12 @@ module Samagotchi
|
|
|
23
24
|
module_function
|
|
24
25
|
|
|
25
26
|
# The keys of +env+ a child chi must not inherit: the guardrails
|
|
26
|
-
# settings, the session markers
|
|
27
|
-
# (InstalledVersions::ENV_KEY, for the spawner's notices only)
|
|
27
|
+
# settings, the session markers, a smoke's fake "newest installed"
|
|
28
|
+
# (InstalledVersions::ENV_KEY, for the spawner's notices only) and the
|
|
29
|
+
# worker's project env marker and record (a child worker applies its own).
|
|
28
30
|
# @return [Array<String>]
|
|
29
31
|
def spawner_keys(env = ENV)
|
|
30
|
-
env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY]
|
|
32
|
+
env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY, ProjectEnv::MARKER_ENV, ProjectEnv::ORIG_ENV]
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# #spawner_keys mapped to nil: Process.spawn unsets a key mapped to nil.
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -138,6 +138,8 @@ module Samagotchi
|
|
|
138
138
|
Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli], lifecycle: :next_retention_sweep),
|
|
139
139
|
Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli], lifecycle: :next_retention_sweep),
|
|
140
140
|
Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli], lifecycle: :worker_start),
|
|
141
|
+
# Tools follow the project: a worker applies its folder's `mise env` at start (ProjectEnv); off keeps the spawner's env.
|
|
142
|
+
Entry.new(key: "session.project_env", yaml_path: %w[session project_env], type: :enum, default: "mise", expose: %i[env config], enum_values: %w[mise off], lifecycle: :worker_start),
|
|
141
143
|
# Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
|
|
142
144
|
Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config], lifecycle: :next_invocation),
|
|
143
145
|
# false: a session nothing happened in is deleted when it is left (SessionManager.discardable?).
|
|
@@ -5,6 +5,7 @@ require_relative "context_sources"
|
|
|
5
5
|
require_relative "context_providers"
|
|
6
6
|
require_relative "log"
|
|
7
7
|
require_relative "process_group"
|
|
8
|
+
require_relative "project_env"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# Runs an attached context source's command once and records the result
|
|
@@ -108,8 +109,9 @@ module Samagotchi
|
|
|
108
109
|
|
|
109
110
|
def env_for(location, name)
|
|
110
111
|
previous = location.snapshot_path(name)
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
# The session's project env (ProjectEnv) under the source's own keys.
|
|
113
|
+
ProjectEnv.tools_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
|
|
114
|
+
"SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil)
|
|
113
115
|
end
|
|
114
116
|
|
|
115
117
|
# @return [Run]
|