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
|
@@ -8,6 +8,7 @@ require_relative "../session_metrics/usage"
|
|
|
8
8
|
require_relative "../guardrails/context"
|
|
9
9
|
require_relative "../hook_question"
|
|
10
10
|
require_relative "../idle_client"
|
|
11
|
+
require_relative "../cache_confirm"
|
|
11
12
|
require_relative "service"
|
|
12
13
|
require_relative "side_question"
|
|
13
14
|
|
|
@@ -58,10 +59,15 @@ module Samagotchi
|
|
|
58
59
|
# plugin handler: +handler+) until the turn has ended.
|
|
59
60
|
# +cost+ returns the session's spend now (SessionMetrics::Cost),
|
|
60
61
|
# +usage+ its work now (SessionMetrics::Usage).
|
|
62
|
+
# +cache_confirm+ takes (line, changed) and returns a
|
|
63
|
+
# CacheConfirm::Confirm when the change must ask first (CacheConfirm.ask
|
|
64
|
+
# with cost :full), else nil. +model_names+ returns the names the
|
|
65
|
+
# session's model is looked up under (EffectiveModel#names).
|
|
61
66
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
62
67
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
63
68
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
|
|
64
|
-
:cancel_controller, :cost, :usage, :turn_author,
|
|
69
|
+
:cancel_controller, :cost, :usage, :turn_author, :cache_confirm, :model_names,
|
|
70
|
+
keyword_init: true)
|
|
65
71
|
|
|
66
72
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
67
73
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -164,6 +170,14 @@ module Samagotchi
|
|
|
164
170
|
# current_model_only names it
|
|
165
171
|
def model_key = @host.model_key&.call
|
|
166
172
|
|
|
173
|
+
# The names the session's model is looked up under now, as chi's own
|
|
174
|
+
# per-model config is (models: maps, ConfigFile.model_setting): as
|
|
175
|
+
# typed (an alias), the alias's target, the resolved ref ("host:id"),
|
|
176
|
+
# without the host, the bare id the host serves; deduped, first the
|
|
177
|
+
# most specific. Compare them downcased, first hit, as chi does.
|
|
178
|
+
# @return [Array<String>] frozen; [] when the model doesn't resolve
|
|
179
|
+
def model_names = Array(@host.model_names&.call).map(&:to_s).freeze
|
|
180
|
+
|
|
167
181
|
# @return [SessionMetrics::Cost] the session's spend so far, read now:
|
|
168
182
|
# #total, #reported, #estimated, #side (Float USD) and #known?
|
|
169
183
|
# (docs/plugins.md); zero and known with no session
|
|
@@ -215,6 +229,25 @@ module Samagotchi
|
|
|
215
229
|
Array(@host.load_tools&.call(names.map(&:to_s), @bundle))
|
|
216
230
|
end
|
|
217
231
|
|
|
232
|
+
# Whether a change the plugin's command is about to make (its prompt
|
|
233
|
+
# section's text: chi.prompt_changed!) must ask first. nil: apply it
|
|
234
|
+
# now (nothing changes, no model reply yet, a sender no one answers
|
|
235
|
+
# for: chi send, a delegate, chi -p, the line had --yes, or not
|
|
236
|
+
# inside a command). Else a CacheConfirm::Confirm: change nothing and
|
|
237
|
+
# return [text, confirm] from the command, so the TUI prefills the
|
|
238
|
+
# line and the web shows Apply (it sends +line+ with --yes). The cost
|
|
239
|
+
# is a full re-read (the section is in the system prompt's stable
|
|
240
|
+
# part); the text says how big and, on api: anthropic, that preserved
|
|
241
|
+
# thinking is dropped.
|
|
242
|
+
# @param line [String] the command line that applies it, without --yes
|
|
243
|
+
# @param changed [Boolean] whether the section's text would change
|
|
244
|
+
# @return [CacheConfirm::Confirm, nil]
|
|
245
|
+
def cache_confirm(line, changed:)
|
|
246
|
+
return nil unless CacheConfirm.asking?
|
|
247
|
+
|
|
248
|
+
@host.cache_confirm&.call(line.to_s, changed ? true : false)
|
|
249
|
+
end
|
|
250
|
+
|
|
218
251
|
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
219
252
|
def scratch? = !!@host.scratch&.call
|
|
220
253
|
|
|
@@ -20,9 +20,12 @@ module Samagotchi
|
|
|
20
20
|
# +init+ adds a chi.init task (Engine#add_init_task); +toolkits+ is the
|
|
21
21
|
# Engine's list of the plugins' toolkits (chi.toolkit: Toolkit::Kit);
|
|
22
22
|
# +tool_index+ the Engine's Tools::ToolIndex (chi.tool_source);
|
|
23
|
-
# +peer_sources+ the Engine's PeerSources (chi.peer_source)
|
|
23
|
+
# +peer_sources+ the Engine's PeerSources (chi.peer_source);
|
|
24
|
+
# +prompt_sections+ the Engine's PromptSections (chi.prompt_section),
|
|
25
|
+
# +prompt_changed+ rebuilds its system prompts (chi.prompt_changed!).
|
|
24
26
|
Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
|
|
25
|
-
:toolkits, :tool_index, :peer_sources,
|
|
27
|
+
:toolkits, :tool_index, :peer_sources, :prompt_sections, :prompt_changed,
|
|
28
|
+
keyword_init: true)
|
|
26
29
|
|
|
27
30
|
# Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
|
|
28
31
|
# into an Engine's registries (docs/plugins.md).
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
require_relative "../toolkit"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Plugin
|
|
8
|
+
# The system-prompt text plugins add (chi.prompt_section): one per
|
|
9
|
+
# (bundle, name), each a block given the plugin's Context that returns
|
|
10
|
+
# a String or nil (no section). SystemPrompt runs them inside a build
|
|
11
|
+
# only (lazy, once per build key until a reset), never per request, on
|
|
12
|
+
# whatever thread builds: a block must be quick and thread-safe.
|
|
13
|
+
#
|
|
14
|
+
# Prompt-cache contract (docs/internals/prompt-caching.md): sections go
|
|
15
|
+
# sorted by (bundle, name), so the text is the same for the same
|
|
16
|
+
# inputs. A block that raises, returns something other than a String
|
|
17
|
+
# or nil, or text over MAX characters keeps the section's last good
|
|
18
|
+
# text for this session (a skip on a transient error would break the
|
|
19
|
+
# cache twice), with one warn; with no good text yet the section is
|
|
20
|
+
# left out.
|
|
21
|
+
class PromptSections
|
|
22
|
+
# As a kit prompt's (Toolkit::PROMPT_MAX): a cached prefix, not a document.
|
|
23
|
+
MAX = Toolkit::PROMPT_MAX
|
|
24
|
+
NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
25
|
+
|
|
26
|
+
Section = Data.define(:bundle, :name, :block, :context)
|
|
27
|
+
|
|
28
|
+
# A section as one build holds it.
|
|
29
|
+
Built = Data.define(:bundle, :name, :text) do
|
|
30
|
+
# "From <bundle> (<name>):\n<text>", as the prompt has it.
|
|
31
|
+
def prompt_text = "From #{bundle} (#{name}):\n#{text}"
|
|
32
|
+
|
|
33
|
+
# /stats' record (Engine#stats_snapshot's prompt_sections).
|
|
34
|
+
def to_stats = { bundle: bundle, name: name }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@sections = []
|
|
39
|
+
@mutex = Mutex.new
|
|
40
|
+
# [session id, bundle, name] => the last good text (nil: none)
|
|
41
|
+
@last_good = {}
|
|
42
|
+
@warned = Set.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @raise [ArgumentError] a (bundle, name) already added
|
|
46
|
+
def add(bundle:, name:, context:, &block)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
if @sections.any? { |section| section.bundle == bundle && section.name == name }
|
|
49
|
+
raise ArgumentError, "prompt section #{name} is registered twice (bundle #{bundle})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@sections << Section.new(bundle: bundle.to_s, name: name.to_s, block: block, context: context)
|
|
53
|
+
@sections.sort_by! { |section| [section.bundle, section.name] }
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def any? = @mutex.synchronize { @sections.any? }
|
|
60
|
+
|
|
61
|
+
# Whether +bundle+ already has a section +name+.
|
|
62
|
+
def registered?(bundle, name)
|
|
63
|
+
@mutex.synchronize { @sections.any? { |section| section.bundle == bundle.to_s && section.name == name.to_s } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Run every block for +session_id+ and keep what they gave.
|
|
67
|
+
# @param session_id [String, nil] whose last good text a failure keeps
|
|
68
|
+
# @return [Array<Built>] in prompt order, the sections with text
|
|
69
|
+
def render(session_id)
|
|
70
|
+
sections = @mutex.synchronize { @sections.dup }
|
|
71
|
+
sections.filter_map do |section|
|
|
72
|
+
text = evaluate(section, session_id)
|
|
73
|
+
Built.new(bundle: section.bundle, name: section.name, text: text) if text
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The prompt text of +built+ (#render), nil without any.
|
|
78
|
+
# @param built [Array<Built>]
|
|
79
|
+
# @return [String, nil]
|
|
80
|
+
def self.text(built)
|
|
81
|
+
built.empty? ? nil : built.map(&:prompt_text).join("\n\n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def evaluate(section, session_id)
|
|
87
|
+
key = [session_id, section.bundle, section.name]
|
|
88
|
+
value = section.block.call(section.context)
|
|
89
|
+
problem = problem_of(value)
|
|
90
|
+
if problem
|
|
91
|
+
keep_last_good(section, key, problem)
|
|
92
|
+
else
|
|
93
|
+
text = value.nil? ? nil : value.strip.freeze
|
|
94
|
+
text = nil if text && text.empty?
|
|
95
|
+
@mutex.synchronize { @last_good[key] = text }
|
|
96
|
+
text
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
keep_last_good(section, key, "#{e.class}: #{e.message}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def problem_of(value)
|
|
103
|
+
return nil if value.nil?
|
|
104
|
+
return "returned #{value.class}, not a String or nil" unless value.is_a?(String)
|
|
105
|
+
|
|
106
|
+
"is over #{MAX} characters" if value.strip.length > MAX
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# One warn per section, session and problem; the last good text, or nil.
|
|
110
|
+
def keep_last_good(section, key, problem)
|
|
111
|
+
kept = @mutex.synchronize do
|
|
112
|
+
first = @warned.add?([*key, problem])
|
|
113
|
+
[@last_good[key], first]
|
|
114
|
+
end
|
|
115
|
+
text, first = kept
|
|
116
|
+
if first
|
|
117
|
+
Log.warn(:plugins, "prompt_section_failed", bundle: section.bundle, section: section.name,
|
|
118
|
+
error: problem.to_s[0, 200], kept: !text.nil?)
|
|
119
|
+
end
|
|
120
|
+
text
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require_relative "chi_home"
|
|
6
|
+
require_relative "chi_shim"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "log"
|
|
9
|
+
require_relative "log_line"
|
|
10
|
+
require_relative "log_path"
|
|
11
|
+
require_relative "paths"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# Tools follow the project: a spawned worker computes its session folder's
|
|
15
|
+
# `mise env` once, at boot (#compute), and the commands it starts for the
|
|
16
|
+
# session (execute, task_create, `!cmd`, MCP servers, context sources) get
|
|
17
|
+
# it merged into their env (#current's Overlay#env), so they see the
|
|
18
|
+
# project's Ruby, node, … whoever started the worker (a terminal on another
|
|
19
|
+
# Ruby, chi web, the macOS helper under launchd). The worker's own process
|
|
20
|
+
# never takes it: its ENV stays the spawner's, so the project can't reach
|
|
21
|
+
# chi's own settings, keys, proxies or dirs (LLM calls, HostKeys, Config,
|
|
22
|
+
# Paths, hooks, plugins). A pair turn's tools don't get it either. A
|
|
23
|
+
# mise.toml edit reaches a session at its next worker (`chi sessions
|
|
24
|
+
# restart`). The REPL modes run no worker and keep the shell's env.
|
|
25
|
+
# docs/sessions.md (Project env).
|
|
26
|
+
#
|
|
27
|
+
# And `chi` inside those tools stays the chi home's (ChiShim): the project's
|
|
28
|
+
# PATH would otherwise find the project Ruby's chi (an old one, or none).
|
|
29
|
+
module ProjectEnv
|
|
30
|
+
# What the worker computed, for `chi self` (it runs in a tool, not in
|
|
31
|
+
# the worker): "mise:<ruby realpath>" or "none:<reason>". Tools only;
|
|
32
|
+
# never a child worker's (ChildEnv).
|
|
33
|
+
MARKER_ENV = "SAMAGOTCHI_PROJECT_ENV"
|
|
34
|
+
# The mise a worker runs, and only that path when set (specs point it at
|
|
35
|
+
# a missing file: the developer's own mise is never asked).
|
|
36
|
+
MISE_CLI_ENV = "SAMAGOTCHI_MISE_CLI"
|
|
37
|
+
# In the tools' env: the worker's own value (or null for unset) of each
|
|
38
|
+
# key the overlay changes, JSON. A chi a tool runs (bin/chi, through the
|
|
39
|
+
# shim or not) takes those values back first thing (#take_orig!), so
|
|
40
|
+
# its own process (API keys, proxies) and the workers it starts (`chi
|
|
41
|
+
# send --new --dir B`) never run on project A's env. A forged one can
|
|
42
|
+
# only set what that tool could set on chi itself; #restore_env never
|
|
43
|
+
# sets a reserved key or a loader variable (RESTORE_NEVER).
|
|
44
|
+
ORIG_ENV = "SAMAGOTCHI_PROJECT_ENV_ORIG"
|
|
45
|
+
# mise's own shell-session state (and any other __MISE_*): never passed
|
|
46
|
+
# to `mise env`, never taken from its answer, unset in the tools' env
|
|
47
|
+
# (they describe the shell mise was activated in).
|
|
48
|
+
MISE_STATE = %w[__MISE_DIFF __MISE_SESSION __MISE_ORIG_PATH MISE_SHELL].freeze
|
|
49
|
+
GEM_KEYS = %w[GEM_HOME GEM_PATH].freeze
|
|
50
|
+
# Keys the tools' overlay never sets: a `chi` a tool runs (the shim's)
|
|
51
|
+
# reads its config, state and log dirs, its settings and the machine its
|
|
52
|
+
# session commands go to (CHI_REMOTE) from them, and the test-run flags.
|
|
53
|
+
# Dropped, with one warning.
|
|
54
|
+
RESERVED_KEYS = %w[HOME RACK_ENV CI CHI_REMOTE].freeze
|
|
55
|
+
RESERVED_PREFIXES = %w[XDG_ SAMAGOTCHI_].freeze
|
|
56
|
+
# Never set from an ORIG_ENV record (a tool can write it), only unset:
|
|
57
|
+
# loader and Bundler variables.
|
|
58
|
+
RESTORE_NEVER = /\A(?:DYLD_|LD_|RUBYOPT\z|RUBYLIB\z|RUBYGEMS_|BUNDLE)/
|
|
59
|
+
# Never passed to `mise env`: MISE_YES answers its trust prompt (it
|
|
60
|
+
# would trust, and remember, an untrusted repo's mise.toml); CI too.
|
|
61
|
+
MISE_UNSET = %w[MISE_YES CI].freeze
|
|
62
|
+
TIMEOUT = 5
|
|
63
|
+
# What execute and tasks set themselves (Tools::TaskRuntime's
|
|
64
|
+
# sanitized env): never taken from the project's.
|
|
65
|
+
TOOLS_DROP = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
66
|
+
|
|
67
|
+
# What #compute found: +env+ the tools' overlay ({key => value, or nil
|
|
68
|
+
# to unset}; frozen, with the marker and the ORIG_ENV record),
|
|
69
|
+
# +applied+ whether mise answered, +reason+ why not (a short token, the
|
|
70
|
+
# marker's and the warning's), +ruby+ the first `ruby` on the tools'
|
|
71
|
+
# PATH (realpath) or nil.
|
|
72
|
+
Overlay = Data.define(:env, :applied, :reason, :ruby) do
|
|
73
|
+
def marker = applied ? "mise:#{ruby || "no ruby"}" : "none:#{reason}"
|
|
74
|
+
end
|
|
75
|
+
# No project env: specs, the REPL modes, a worker before #install!.
|
|
76
|
+
NONE = Overlay.new(env: {}.freeze, applied: false, reason: "none", ruby: nil)
|
|
77
|
+
|
|
78
|
+
class Failed < StandardError; end
|
|
79
|
+
|
|
80
|
+
# chi home doctor's row when session.project_env is mise and no mise is found.
|
|
81
|
+
NO_MISE = "project env: no mise found (home.json's, PATH, /opt/homebrew/bin, /usr/local/bin, ~/.local/bin): " \
|
|
82
|
+
"tools run in the env of whoever starts the worker; install mise, or set session.project_env: off"
|
|
83
|
+
# How much of the debug log's end chi home doctor reads for #last_warning.
|
|
84
|
+
LOG_TAIL_BYTES = 4 * 1024 * 1024
|
|
85
|
+
|
|
86
|
+
# A worker's `project_env` warning from the debug log (#last_warning).
|
|
87
|
+
Warning = Data.define(:ts, :sid, :dir, :reason, :error) do
|
|
88
|
+
# chi home doctor's row: what failed and what to run.
|
|
89
|
+
def message
|
|
90
|
+
detail = error.to_s.start_with?("#{reason}:") ? error : [reason, error].compact.reject(&:empty?).join(": ")
|
|
91
|
+
fix = error.to_s.include?("mise trust") || error.to_s.include?("not trusted") ? "mise trust in #{dir}" : "fix that"
|
|
92
|
+
restart = sid ? "chi sessions restart #{sid}" : "chi sessions restart ID"
|
|
93
|
+
"the last worker in #{dir} (#{ts}) got no project env (#{detail}): #{fix}, then #{restart}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@current = NONE
|
|
98
|
+
|
|
99
|
+
module_function
|
|
100
|
+
|
|
101
|
+
# The overlay this process's tools get: the worker's, set once at boot
|
|
102
|
+
# (#install!); NONE elsewhere.
|
|
103
|
+
# @return [Overlay]
|
|
104
|
+
def current = @current
|
|
105
|
+
|
|
106
|
+
# Only from SessionManager.run_session_loop, in a spawned worker.
|
|
107
|
+
def install!(overlay) = @current = overlay
|
|
108
|
+
|
|
109
|
+
# #current's env for a tool's spawn, less what every tool runs without
|
|
110
|
+
# (Tools::TaskRuntime::SANITIZED_ENV_KEYS: RUBYOPT, RUBYLIB, Bundler's).
|
|
111
|
+
# @return [Hash{String=>String, nil}]
|
|
112
|
+
def tools_env = current.env.except(*TOOLS_DROP)
|
|
113
|
+
|
|
114
|
+
# Specs: back to NONE.
|
|
115
|
+
def reset! = @current = NONE
|
|
116
|
+
|
|
117
|
+
# The tools' overlay for the session folder +dir+, from +env+ (the
|
|
118
|
+
# worker's ENV, read, never written).
|
|
119
|
+
# @param dir [String] the session's working_directory
|
|
120
|
+
# @param mode [String] session.project_env: "mise" or "off"
|
|
121
|
+
# @param home [ChiHome::Record, nil] the chi home (the shim's)
|
|
122
|
+
# @param runner [#call] (env, argv, timeout:) → stdout (+env+ is the child's whole env); raises Failed
|
|
123
|
+
# @return [Overlay]
|
|
124
|
+
def compute(dir, env: ENV, mode: configured_mode, home: ChiHome.current, cwd: Dir.pwd,
|
|
125
|
+
runner: method(:run_mise), shim_dir: ChiShim.dir(env: env), timeout: TIMEOUT)
|
|
126
|
+
base = env.to_h.dup
|
|
127
|
+
tools = base.dup
|
|
128
|
+
applied, reason = mise_overlay(dir, tools, mode: mode, cwd: cwd, runner: runner, timeout: timeout)
|
|
129
|
+
prepend_shim(tools, home: home, shim_dir: shim_dir)
|
|
130
|
+
overlay = Overlay.new(env: {}, applied: applied, reason: reason, ruby: applied ? ruby_on(tools["PATH"]) : nil)
|
|
131
|
+
changes = diff(base, tools)
|
|
132
|
+
changes[ORIG_ENV] = JSON.generate(changes.keys.to_h { |key| [key, base[key]] }) unless changes.empty?
|
|
133
|
+
changes[MARKER_ENV] = overlay.marker
|
|
134
|
+
overlay.with(env: changes.freeze)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Applies the mise env to +tools+ (a copy of the worker's env).
|
|
138
|
+
# @return [Array(Boolean, String)] applied, reason
|
|
139
|
+
def mise_overlay(dir, tools, mode:, cwd:, runner:, timeout:)
|
|
140
|
+
return skipped("off") if mode == "off"
|
|
141
|
+
return skipped("no_dir") if dir.to_s.empty?
|
|
142
|
+
return skipped("cwd_mismatch", warn: { dir: dir, cwd: cwd }) unless same_dir?(dir, cwd)
|
|
143
|
+
|
|
144
|
+
mise = find_mise(env: tools)
|
|
145
|
+
return skipped("no_mise") unless mise
|
|
146
|
+
|
|
147
|
+
answer = parse(runner.call(mise_env(tools), [mise, "env", "-C", dir, "--json"], timeout: timeout))
|
|
148
|
+
answer = without_reserved(answer, dir)
|
|
149
|
+
before = ruby_on(tools["PATH"])
|
|
150
|
+
tools.delete_if { |key, _| mise_state?(key) }
|
|
151
|
+
tools.update(answer)
|
|
152
|
+
drop_gem_env(tools, answer, before, ruby_on(tools["PATH"]))
|
|
153
|
+
[true, nil]
|
|
154
|
+
rescue Failed => e
|
|
155
|
+
skipped(e.message.split(":", 2).first, warn: { dir: dir, error: e.message })
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# {key => new value, or nil for a removed key} between +before+ and +after+.
|
|
159
|
+
def diff(before, after)
|
|
160
|
+
changed = (before.keys | after.keys).reject { |key| before[key] == after[key] }
|
|
161
|
+
changed.to_h { |key| [key, after[key]] }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# #ORIG_ENV of +env+: {key => value or nil}; {} without one (or one that
|
|
165
|
+
# isn't its shape). Reserved keys never come back; a RESTORE_NEVER key
|
|
166
|
+
# only as an unset (project A's LD_LIBRARY_PATH goes, nothing comes).
|
|
167
|
+
# @return [Hash{String=>String, nil}]
|
|
168
|
+
def restore_env(env = ENV)
|
|
169
|
+
data = JSON.parse(env[ORIG_ENV].to_s)
|
|
170
|
+
return {} unless data.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
data.select do |key, value|
|
|
173
|
+
next false unless key.is_a?(String) && !reserved?(key)
|
|
174
|
+
|
|
175
|
+
value.nil? || (value.is_a?(String) && !key.match?(RESTORE_NEVER))
|
|
176
|
+
end
|
|
177
|
+
rescue JSON::ParserError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# bin/chi, before anything reads config: +env+ back to the worker's
|
|
182
|
+
# values (#restore_env), the record taken out. The marker stays (chi
|
|
183
|
+
# self reads it).
|
|
184
|
+
def take_orig!(env = ENV)
|
|
185
|
+
restore_env(env).each { |key, value| value.nil? ? env.delete(key) : env[key] = value }
|
|
186
|
+
env.delete(ORIG_ENV)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def reserved?(key) = RESERVED_KEYS.include?(key) || key.start_with?(*RESERVED_PREFIXES)
|
|
190
|
+
|
|
191
|
+
# +answer+ without RESERVED_KEYS/RESERVED_PREFIXES, warned once by name.
|
|
192
|
+
def without_reserved(answer, dir)
|
|
193
|
+
dropped = answer.keys.select { |key| reserved?(key) }
|
|
194
|
+
Log.warn(:worker, "project_env_reserved", dir: dir, keys: dropped.sort.join(",")) unless dropped.empty?
|
|
195
|
+
answer.except(*dropped)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The first one found: SAMAGOTCHI_MISE_CLI when set (that path only),
|
|
199
|
+
# else home.json's mise, else PATH and the usual dirs (ChiHome.find_mise).
|
|
200
|
+
# @return [String, nil]
|
|
201
|
+
def find_mise(env: ENV)
|
|
202
|
+
cli = env[MISE_CLI_ENV].to_s
|
|
203
|
+
return (executable?(cli) ? cli : nil) unless cli.empty?
|
|
204
|
+
|
|
205
|
+
home_mise = home_json_mise(env)
|
|
206
|
+
return home_mise if home_mise && executable?(home_mise)
|
|
207
|
+
|
|
208
|
+
ChiHome.find_mise(env: env)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The GEM_* rule: the spawner's GEM_HOME/GEM_PATH are its Ruby's; when
|
|
212
|
+
# the project env moves `ruby` to another one (or exactly one of the two
|
|
213
|
+
# is a mise shim) they are dropped, unless mise sets them itself. Neither
|
|
214
|
+
# resolving keeps them.
|
|
215
|
+
def drop_gem_env(env, answer, before, after)
|
|
216
|
+
return if before.nil? && after.nil?
|
|
217
|
+
return unless before != after || (mise_shim?(before) ^ mise_shim?(after))
|
|
218
|
+
|
|
219
|
+
GEM_KEYS.each { |key| env.delete(key) unless answer.key?(key) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The realpath of the first executable `ruby` on +path+, or nil.
|
|
223
|
+
def ruby_on(path)
|
|
224
|
+
path.to_s.split(File::PATH_SEPARATOR).each do |dir|
|
|
225
|
+
next if dir.empty?
|
|
226
|
+
|
|
227
|
+
file = File.join(dir, "ruby")
|
|
228
|
+
return File.realpath(file) if File.file?(file) && File.executable?(file)
|
|
229
|
+
rescue SystemCallError
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def mise_shim?(real) = !real.nil? && File.basename(real) == "mise"
|
|
236
|
+
|
|
237
|
+
# PATH with the shim's dir first (once), when there is a home to shim.
|
|
238
|
+
def prepend_shim(env, home:, shim_dir:)
|
|
239
|
+
return unless home && ChiShim.ensure!(home, dir: shim_dir)
|
|
240
|
+
|
|
241
|
+
dirs = env["PATH"].to_s.split(File::PATH_SEPARATOR).reject { |dir| dir.empty? || dir == shim_dir }
|
|
242
|
+
env["PATH"] = [shim_dir, *dirs].join(File::PATH_SEPARATOR)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Hash{String=>String}]
|
|
246
|
+
# @raise [Failed]
|
|
247
|
+
def parse(stdout)
|
|
248
|
+
data = JSON.parse(stdout.to_s)
|
|
249
|
+
raise Failed, "bad_json: not an object" unless data.is_a?(Hash)
|
|
250
|
+
|
|
251
|
+
data.select { |key, value| key.is_a?(String) && value.is_a?(String) && !mise_state?(key) }
|
|
252
|
+
rescue JSON::ParserError => e
|
|
253
|
+
raise Failed, "bad_json: #{e.message.lines.first.to_s.strip[0, 120]}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The whole env `mise env` runs with (nothing else inherited): the
|
|
257
|
+
# worker's, less mise's shell state (an activated shell's __MISE_DIFF
|
|
258
|
+
# would make it answer relative to that shell) and MISE_UNSET (its
|
|
259
|
+
# MISE_TRUSTED_CONFIG_PATHS stays: the user's own trust list).
|
|
260
|
+
def mise_env(env) = env.to_h.reject { |key, _| mise_state?(key) || MISE_UNSET.include?(key) }
|
|
261
|
+
|
|
262
|
+
def mise_state?(key) = key.start_with?("__MISE_") || MISE_STATE.include?(key)
|
|
263
|
+
|
|
264
|
+
# @return [String] stdout
|
|
265
|
+
# @raise [Failed]
|
|
266
|
+
def run_mise(child_env, argv, timeout:)
|
|
267
|
+
Open3.popen3(child_env, *argv, pgroup: true, unsetenv_others: true) do |stdin, out, err, wait|
|
|
268
|
+
stdin.close
|
|
269
|
+
readers = [out, err].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
|
|
270
|
+
unless wait.join(timeout)
|
|
271
|
+
kill_group(wait.pid)
|
|
272
|
+
wait.join(1)
|
|
273
|
+
readers.each { |reader| reader.join(1) }
|
|
274
|
+
raise Failed, "mise_timeout: no answer in #{timeout} s"
|
|
275
|
+
end
|
|
276
|
+
readers.each { |reader| reader.join(1) }
|
|
277
|
+
stdout, stderr = readers.map { |reader| reader.alive? ? "" : reader.value.to_s }
|
|
278
|
+
status = wait.value
|
|
279
|
+
raise Failed, "mise_failed: exit #{status.exitstatus}: #{brief(stderr)}" unless status.success?
|
|
280
|
+
|
|
281
|
+
stdout
|
|
282
|
+
end
|
|
283
|
+
rescue SystemCallError => e
|
|
284
|
+
raise Failed, "mise_failed: #{e.message}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def kill_group(pid)
|
|
288
|
+
Process.kill("KILL", -pid)
|
|
289
|
+
rescue SystemCallError
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# mise's stderr in one line, without its noise ("mise ERROR Config files
|
|
294
|
+
# in … are not trusted. / Trust them with `mise trust`…").
|
|
295
|
+
MISE_NOISE = /\AVersion:|--verbose|MISE_VERBOSE/
|
|
296
|
+
def brief(text)
|
|
297
|
+
text.to_s.lines.map { |line| line.strip.delete_prefix("mise ERROR ").strip }
|
|
298
|
+
.reject { |line| line.empty? || line.match?(MISE_NOISE) }.join(" / ")[0, 600]
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @return [Array(false, String)]
|
|
302
|
+
def skipped(reason, warn: nil)
|
|
303
|
+
Log.warn(:worker, "project_env", reason: reason, **warn) if warn
|
|
304
|
+
[false, reason]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def same_dir?(dir, cwd)
|
|
308
|
+
!dir.to_s.empty? && ChiHome.real(dir) == ChiHome.real(cwd)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def executable?(file) = File.file?(file) && File.executable?(file)
|
|
312
|
+
|
|
313
|
+
def home_json_mise(env)
|
|
314
|
+
ChiHome.read(path: ChiHome.path(env: env))&.mise
|
|
315
|
+
rescue ChiHome::Invalid
|
|
316
|
+
nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# The newest worker record in the debug log for one of the folders
|
|
320
|
+
# +dirs+ (a folder and the ones above it in its project), when it is a
|
|
321
|
+
# `project_env` warning (an untrusted mise.toml, a timeout); nil when
|
|
322
|
+
# the newest is `project_env_applied`, there is none, or the log can't
|
|
323
|
+
# be read. Reads the log's last LOG_TAIL_BYTES only.
|
|
324
|
+
# @return [Warning, nil]
|
|
325
|
+
def last_warning(dirs, log_path: LogPath.resolve)
|
|
326
|
+
return nil unless log_path && File.file?(log_path)
|
|
327
|
+
|
|
328
|
+
want = Array(dirs).map { |dir| ChiHome.real(dir) }
|
|
329
|
+
newest = {}
|
|
330
|
+
each_tail_record(log_path) do |record|
|
|
331
|
+
next unless record.tag == "worker" && %w[project_env project_env_applied].include?(record.event)
|
|
332
|
+
|
|
333
|
+
dir = record.fields["dir"]
|
|
334
|
+
newest[ChiHome.real(dir)] = record if dir && want.include?(ChiHome.real(dir))
|
|
335
|
+
end
|
|
336
|
+
found = newest.values.select { |record| record.event == "project_env" }.max_by(&:ts)
|
|
337
|
+
return nil unless found
|
|
338
|
+
|
|
339
|
+
Warning.new(ts: found.ts, sid: found.sid, dir: Paths.tilde(found.fields["dir"]),
|
|
340
|
+
reason: found.fields["reason"], error: found.fields["error"])
|
|
341
|
+
rescue SystemCallError
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def each_tail_record(log_path, &)
|
|
346
|
+
File.open(log_path, "rb") do |io|
|
|
347
|
+
size = io.size
|
|
348
|
+
io.seek([size - LOG_TAIL_BYTES, 0].max)
|
|
349
|
+
io.gets if size > LOG_TAIL_BYTES # a torn first line
|
|
350
|
+
LogLine.each_record(io, &)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def configured_mode
|
|
355
|
+
Config.get("session.project_env").to_s
|
|
356
|
+
rescue StandardError
|
|
357
|
+
"mise"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
@@ -210,6 +210,7 @@ module Samagotchi
|
|
|
210
210
|
chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
|
|
211
211
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
212
212
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
213
|
+
chi home [set [--ruby PATH] | doctor] the Ruby every chi launch boots
|
|
213
214
|
chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
|
|
214
215
|
chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
|
|
215
216
|
chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
|
|
@@ -24,6 +24,8 @@ require_relative "memory_bundle/index_size"
|
|
|
24
24
|
require_relative "desktop"
|
|
25
25
|
require_relative "live_versions"
|
|
26
26
|
require_relative "web/lan"
|
|
27
|
+
require_relative "chi_home"
|
|
28
|
+
require_relative "project_env"
|
|
27
29
|
|
|
28
30
|
module Samagotchi
|
|
29
31
|
# `chi self`: where this chi lives and what it is configured to use.
|
|
@@ -50,6 +52,8 @@ module Samagotchi
|
|
|
50
52
|
[
|
|
51
53
|
["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
|
|
52
54
|
["source", "#{SOURCE_DIR} (#{install_kind})"],
|
|
55
|
+
["home", home_summary(env)],
|
|
56
|
+
["project env", project_env_summary(env)],
|
|
53
57
|
["config", with_presence(ConfigFile.global_path(env: env))],
|
|
54
58
|
["hooks dir", hooks_dir(env)],
|
|
55
59
|
["memories", Tools::MemoryRead.memories_dir("system", env: env)],
|
|
@@ -414,6 +418,35 @@ module Samagotchi
|
|
|
414
418
|
helper.stale? ? "#{version} (chi is #{VERSION}: chi update)" : "#{version} (up to date for chi #{VERSION})"
|
|
415
419
|
end
|
|
416
420
|
|
|
421
|
+
# The chi home (home.json): its ruby and newest chi, and whether this
|
|
422
|
+
# chi (the one answering) runs on it.
|
|
423
|
+
def home_summary(env)
|
|
424
|
+
home = ChiHome.read(path: ChiHome.path(env: env))
|
|
425
|
+
return "none (chi home set records one)" unless home
|
|
426
|
+
|
|
427
|
+
problem = ChiHome.problem(home)
|
|
428
|
+
return "#{home.ruby} (unusable: #{problem})" if problem
|
|
429
|
+
|
|
430
|
+
label = "#{home.ruby} (chi #{home.newest_version})"
|
|
431
|
+
return "#{label}; this chi is a source checkout" unless InstalledGem.spec
|
|
432
|
+
return label if home.running_here?
|
|
433
|
+
|
|
434
|
+
"#{label}; this chi runs on #{RbConfig.ruby}"
|
|
435
|
+
rescue ChiHome::Invalid => e
|
|
436
|
+
"(invalid: #{e.message})"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# What this session's worker took from the project (ProjectEnv's marker,
|
|
440
|
+
# exported to the tools it runs): "mise (ruby PATH)" or "none (why)".
|
|
441
|
+
def project_env_summary(env)
|
|
442
|
+
kind, detail = env[ProjectEnv::MARKER_ENV].to_s.split(":", 2)
|
|
443
|
+
case kind
|
|
444
|
+
when "mise" then "mise (ruby #{detail})"
|
|
445
|
+
when "none" then "none (#{detail})"
|
|
446
|
+
else "- (not run by a session's worker)"
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
417
450
|
# "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
|
|
418
451
|
def bundles_summary
|
|
419
452
|
shipped = MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version rescue nil
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
# bin/chi subcommands that act on this machine: never sessions. chi
|
|
27
27
|
# remote (this machine's list of remotes) runs here under a remote too
|
|
28
28
|
# (Dispatch); the others are refused there.
|
|
29
|
-
MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote].freeze
|
|
29
|
+
MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote home].freeze
|
|
30
30
|
|
|
31
31
|
# "command", "command sub" or either with a flag (its long name) →
|
|
32
32
|
# SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
|