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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/bin/chi +19 -1
  4. data/docs/architecture.md +59 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +115 -16
  7. data/docs/desktop.md +16 -6
  8. data/docs/guardrails.md +10 -4
  9. data/docs/internals/anthropic.md +3 -2
  10. data/docs/internals/engine.md +4 -4
  11. data/docs/internals/prompt-caching.md +26 -6
  12. data/docs/memory.md +16 -2
  13. data/docs/plugins.md +68 -1
  14. data/docs/sessions.md +60 -6
  15. data/docs/testing.md +1 -1
  16. data/lib/samagotchi/bootstrap_command.rb +35 -6
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
  19. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  20. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  21. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/cache_confirm.rb +18 -0
  24. data/lib/samagotchi/chi_home.rb +311 -0
  25. data/lib/samagotchi/chi_shim.rb +77 -0
  26. data/lib/samagotchi/child_env.rb +5 -3
  27. data/lib/samagotchi/config.rb +2 -0
  28. data/lib/samagotchi/context_fetch.rb +4 -2
  29. data/lib/samagotchi/desktop/macos.rb +100 -18
  30. data/lib/samagotchi/engine.rb +35 -4
  31. data/lib/samagotchi/engine_snapshots.rb +5 -0
  32. data/lib/samagotchi/gem_update.rb +19 -10
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
  35. data/lib/samagotchi/guardrails/pair_command.rb +57 -1
  36. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  37. data/lib/samagotchi/guardrails/review_paths.rb +7 -4
  38. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  39. data/lib/samagotchi/home_command.rb +145 -0
  40. data/lib/samagotchi/home_doctor.rb +177 -0
  41. data/lib/samagotchi/home_notice.rb +80 -0
  42. data/lib/samagotchi/installed_versions.rb +5 -3
  43. data/lib/samagotchi/llm_context_command.rb +53 -7
  44. data/lib/samagotchi/llm_context_override.rb +3 -2
  45. data/lib/samagotchi/plugin/api.rb +45 -1
  46. data/lib/samagotchi/plugin/context.rb +34 -1
  47. data/lib/samagotchi/plugin/loader.rb +5 -2
  48. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  49. data/lib/samagotchi/project_env.rb +360 -0
  50. data/lib/samagotchi/run_options.rb +1 -0
  51. data/lib/samagotchi/self_report.rb +33 -0
  52. data/lib/samagotchi/session_access/contract.rb +1 -1
  53. data/lib/samagotchi/session_commands.rb +68 -19
  54. data/lib/samagotchi/session_manager.rb +54 -1
  55. data/lib/samagotchi/session_model_settings.rb +5 -2
  56. data/lib/samagotchi/system_prompt.rb +34 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  59. data/lib/samagotchi/testing/boundary.rb +3 -1
  60. data/lib/samagotchi/testing/bundle.rb +22 -3
  61. data/lib/samagotchi/testing/host.rb +22 -1
  62. data/lib/samagotchi/testing/recorder.rb +6 -1
  63. data/lib/samagotchi/testing/recording_api.rb +1 -1
  64. data/lib/samagotchi/tools/builtins.rb +13 -2
  65. data/lib/samagotchi/update_command.rb +65 -7
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +61 -1
  68. data/lib/samagotchi/web/public/app.js +4 -1
  69. data/lib/samagotchi/web/public/chip_popover.js +5 -2
  70. data/lib/samagotchi/web/public/composer.css +11 -1
  71. data/lib/samagotchi/web/public/data.js +8 -4
  72. data/lib/samagotchi/web/public/index.html +52 -10
  73. data/lib/samagotchi/web/public/level_chip.js +22 -38
  74. data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
  75. data/lib/samagotchi/web/public/model_pick.js +26 -0
  76. data/lib/samagotchi/web/public/model_picker.js +3 -1
  77. data/lib/samagotchi/web/public/review_start.js +13 -57
  78. data/lib/samagotchi/web/public/send_turn.js +3 -2
  79. data/lib/samagotchi/web/public/start_page.js +131 -99
  80. data/lib/samagotchi/web/public/start_settings.js +298 -0
  81. data/lib/samagotchi/web/public/start_toolkit.js +157 -0
  82. data/lib/samagotchi/web/public/thinking_chip.js +9 -22
  83. data/lib/samagotchi/web/server.rb +27 -8
  84. data/lib/samagotchi/web/update_check.rb +3 -2
  85. 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, keyword_init: true)
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, keyword_init: true)
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