samagotchi 0.131.0 → 0.132.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 +47 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +32 -1
- data/docs/cli.md +29 -8
- data/docs/configuration.md +62 -6
- data/docs/desktop.md +15 -6
- data/docs/guardrails.md +3 -3
- 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/lib/samagotchi/bootstrap_command.rb +35 -6
- 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/desktop/macos.rb +75 -19
- 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/protected_paths.rb +6 -3
- data/lib/samagotchi/guardrails/verdict.rb +1 -1
- data/lib/samagotchi/home_command.rb +149 -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/run_options.rb +1 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +64 -17
- data/lib/samagotchi/session_manager.rb +36 -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/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/update_command.rb +65 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +11 -1
- data/lib/samagotchi/web/public/index.html +6 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -3
- 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/start_page.js +14 -2
- data/lib/samagotchi/web/server.rb +27 -8
- data/lib/samagotchi/web/update_check.rb +3 -2
- metadata +5 -1
data/docs/plugins.md
CHANGED
|
@@ -150,6 +150,34 @@ chi.command "/hello-slow", "greet after 2 s, even mid-turn", anytime: true do |a
|
|
|
150
150
|
end
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
+
#### Commands that ask: `ctx.cache_confirm`
|
|
154
|
+
|
|
155
|
+
A command whose change restarts the model server's prompt cache (one that changes the text of a
|
|
156
|
+
[prompt section](#chiprompt_sectionname--ctx---and-chiprompt_changed)) asks first, as `/thinking` and `/toolkit` do:
|
|
157
|
+
`ctx.cache_confirm(line, changed:)` returns nil when it may apply now, or a confirm to return with the command's
|
|
158
|
+
text, `[text, confirm]`, changing nothing. `line` is the command line that applies it (without `--yes`), `changed:`
|
|
159
|
+
whether your text would change. chi decides the rest: it asks only a person at a web tab or a TUI (never `chi send`,
|
|
160
|
+
a delegate or `chi -p`), only once the session has a model reply, never for a line with `--yes` (or `-y`), and
|
|
161
|
+
writes the confirm's text (the conversation's size to read again; on an `api: anthropic` host, that preserved
|
|
162
|
+
thinking is dropped). The TUI prefills `line --yes`, the web shows an Apply button that sends it, and the command
|
|
163
|
+
then runs again with `--yes` in `args` (drop it from your words). Outside a command (a hook, an init task, your
|
|
164
|
+
own thread) it is always nil. A plain String or nil return is unchanged.
|
|
165
|
+
|
|
166
|
+
A command that asks must not be `anytime: true`: the REPL shows only an anytime command's text, so its Apply would
|
|
167
|
+
be lost. Needs chi 0.132.0.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
chi.command "/tone", "show or set this session's tone" do |args, ctx|
|
|
171
|
+
word = (args.split - %w[--yes -y]).join(" ")
|
|
172
|
+
ask = ctx.cache_confirm("/tone #{word}", changed: word != current_tone)
|
|
173
|
+
next ["Switching to #{word} restarts the prompt cache.", ask] if ask
|
|
174
|
+
|
|
175
|
+
save_tone(word)
|
|
176
|
+
@chi.prompt_changed!
|
|
177
|
+
"tone: #{word} from the next turn"
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
153
181
|
#### `/help`
|
|
154
182
|
|
|
155
183
|
`/help` (itself an anytime command) lists every command the session knows:
|
|
@@ -607,6 +635,38 @@ chi.toolkit "coding", base: "child", prompt: "- Be concise in your responses"
|
|
|
607
635
|
chi.toolkit "web", base: "lean", groups: %w[chrome db*]
|
|
608
636
|
```
|
|
609
637
|
|
|
638
|
+
### `chi.prompt_section(name) { |ctx| … }` and `chi.prompt_changed!`
|
|
639
|
+
|
|
640
|
+
Text the session's system prompt carries, from a plugin: the block gets the Context and returns a String (at most
|
|
641
|
+
2000 characters, stripped) or nil for no section. Each goes into the prompt's stable part right after the
|
|
642
|
+
toolkit's prompt text and before the memory indexes, as `From <bundle> (<name>):` and the text, sorted by
|
|
643
|
+
(bundle, name). With no section (every block nil, or no plugin uses it) the prompt is exactly what it was without.
|
|
644
|
+
`name` is `a-z`, `0-9`, `_` and `-` (at most 32), once per bundle.
|
|
645
|
+
|
|
646
|
+
The block runs when the prompt is built: a session's first turn, after `/model` (so `ctx.model` and
|
|
647
|
+
`ctx.model_key` are the new model's), after `chi.prompt_changed!` or a tools change. Never per request, and on
|
|
648
|
+
whatever thread builds, so keep it quick and thread-safe (no I/O beyond reading a small file). **Same inputs, same
|
|
649
|
+
text**: anything that differs (a time, a random pick, a count) breaks the model server's prompt cache at the next
|
|
650
|
+
build. A block that raises, returns something other than a String or nil, or text over the limit keeps that
|
|
651
|
+
section's last good text for the session (one warn in the debug log, `prompt_section_failed`); with none yet the
|
|
652
|
+
section is left out. A deliberate nil removes it.
|
|
653
|
+
|
|
654
|
+
`chi.prompt_changed!` says your section would give other text now (the user picked another setting): the prompt is
|
|
655
|
+
built again at its next build, the next turn's start (a running turn keeps the prompt it started with). That
|
|
656
|
+
restarts the prompt cache for the whole conversation, so a command that changes it asks first
|
|
657
|
+
([Commands that ask](#commands-that-ask-ctxcache_confirm)) and calls `chi.prompt_changed!` only once it applies.
|
|
658
|
+
Keep `chi` from `register` for that (`@chi = chi`). `/stats` names the sections the prompt holds
|
|
659
|
+
(`prompt sections: tone (tone)`). The test kit runs them as a build does (`bundle.prompt_text`,
|
|
660
|
+
`bundle.prompt_sections`) and counts `chi.prompt_changed!` (`bundle.prompt_changes`). Needs chi
|
|
661
|
+
0.132.0: a bundle using it sets `requires_chi`.
|
|
662
|
+
|
|
663
|
+
```ruby
|
|
664
|
+
def register(chi)
|
|
665
|
+
@chi = chi
|
|
666
|
+
chi.prompt_section("tone") { |ctx| preset_text(ctx.model_key) } # nil: no section
|
|
667
|
+
end
|
|
668
|
+
```
|
|
669
|
+
|
|
610
670
|
### `chi.tool_source(call:, acts_as:, label:, wait:, prefix:)`
|
|
611
671
|
|
|
612
672
|
Tools the model reaches through a search instead of declaring them one by
|
|
@@ -764,6 +824,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
764
824
|
| `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
|
|
765
825
|
| `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
|
|
766
826
|
| `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
|
|
827
|
+
| `ctx.model_names` | the names chi's own per-model config looks that model up under (a `models:` map in config.yml): as typed (an alias), the alias's target, the resolved ref, without the host, the bare id; deduped, most specific first, frozen (`[]` when it doesn't resolve). Match a map of yours over them downcased, first hit, as chi does. Needs chi 0.132.0 |
|
|
767
828
|
| `ctx.cost` | the session's spend so far, read now, as a frozen value: `total` (USD, reported + estimated, side requests included; 0.0 before any), `reported` (by providers, `usage.cost`), `estimated` (from `hosts.<name>.models` prices; a generation is one or the other, never both), `side` (recap, `/btw`, `ask_model`: their share of `total`) and `known?` (false while generations or side requests ran but none was reported or priced, so a limit on it can't be enforced; a session with some priced generations reads known, the unpriced ones counting 0). It includes the running turn's finished generations and what earlier workers of the session spent. Use its methods, not its class. Needs chi 0.83.0 |
|
|
768
829
|
| `ctx.usage` | the session's work so far, read now, as a frozen value: `turns` (finished ones and the running one), `tool_calls` and `tool_calls_by_tool` (every call, running ones included; canonical names: an alias counts under its tool, a `call_tool` call under the inner tool), `steps` (the loop rounds that dispatched tools, `/stats`' iterations) and `active_ms` (`active_minutes` too): work time, the generations' time plus the finished tool calls' time, never the waits between them (a question, an approval, the user's answer to `ask_user_question`), with `tool_ms_by_tool` its tool part by tool, so a waiting tool's time (`task_wait`) can be left out. Its counts are the ones `/stats` and `chi sessions stats` show, earlier workers' included. Use its methods, not its class. Needs chi 0.100.0 (guard it with `ctx.respond_to?(:usage)`) |
|
|
769
830
|
| `ctx.turn_author` | who wrote the running turn when it isn't the session's owner: a participant's `"p:<id>"` (`chi share`); nil for the owner's turns and between turns. Every hook event of such a turn carries the same as `author:`. The handler runs with the owner's power whatever the author ([hooks](hooks.md#participants-turns)). Needs chi 0.105.0 (guard it with `ctx.respond_to?(:turn_author)`) |
|
|
@@ -788,6 +849,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
788
849
|
| `ctx.use_toolkit(name)` | put the session on toolkit `name` (yours from `chi.toolkit`, or chi's): saved, applied at the next turn's start and announced (`toolkit> toolkit: NAME from the next turn's start`, the web's tools chip follows). Only before the session's first model reply and outside a turn (the declared tools start every request, and a plugin doesn't ask first); otherwise it returns false and changes nothing: tell the user to run `/toolkit NAME`. An unknown name raises ArgumentError. Needs chi 0.66.0 |
|
|
789
850
|
| `ctx.load_tools(names)` | load tools the session reaches but doesn't declare, so the model calls them by name: built-ins (`"delegate"`, `"chi/delegate"`), MCP tools (`"mcp_fs_read"`, `"fs/read"`). Returns the names it staged (the wire names); a declared tool, one loaded before, a removed or unknown one is skipped (an unknown one logged), so calling it every turn is fine. From a `before_turn` hook it loads for that turn; anywhere else (mid-turn too) from the next turn's start. Call it from a hook: before the session's first turn its toolkit isn't applied yet and nothing resolves. **A hint, not access**: the tools keep their guardrails and approvals, and only a host that takes tool changes (`api: anthropic`, not Fireworks) gets one, as a system message after the user's (`[tools loaded: delegate (your-bundle)]` in the session, a notice line live); elsewhere nothing is sent or saved, and the tools stay reachable with `call_tool`. A hook may switch the model after you call it, so the return value can't promise delivery. Raises ArgumentError for anything but an Array. Needs chi 0.99.0 |
|
|
790
851
|
| `ctx.context` | attach outside text to this session and list it: see [Attached context](#attached-context-ctxcontext) |
|
|
852
|
+
| `ctx.cache_confirm(line, changed:)` | inside a command: nil to apply a prompt-cache-restarting change now, or a confirm to return as `[text, confirm]`: see [Commands that ask](#commands-that-ask-ctxcache_confirm). Needs chi 0.132.0 |
|
|
791
853
|
|
|
792
854
|
The Engine itself is never handed to a plugin.
|
|
793
855
|
|
|
@@ -1211,6 +1273,10 @@ bundle.fire(:after_turn, status: "completed") # → the event, after
|
|
|
1211
1273
|
bundle.question_asked("Fix JIRA-1?", question: { question: "Go?" }) # → the refs, as chi's seams fire it
|
|
1212
1274
|
event = bundle.before_tool_call_event({ name: "read", path: "a.txt" }, params: "read a.txt")
|
|
1213
1275
|
bundle.command("/mine stop") # → what the command returned
|
|
1276
|
+
bundle.command("/tone x", confirm: true) # sent from a web tab or a TUI: ctx.cache_confirm may ask
|
|
1277
|
+
bundle.set(history: true, context_tokens: 900) # a model reply to restart (ctx.cache_confirm), its size
|
|
1278
|
+
bundle.set(model_names: %w[tiny box:gemma gemma]) # ctx.model_names (default: model_name and its id)
|
|
1279
|
+
bundle.prompt_text # the prompt sections' text, as a build runs them
|
|
1214
1280
|
bundle.tool("my_tool", path: "a.txt") # → the text the model reads
|
|
1215
1281
|
verdict = bundle.tool_call({ name: "execute", content: "rm -rf /" })
|
|
1216
1282
|
verdict.deny? # a real Guardrails::Verdict (votes: verdict.votes)
|
|
@@ -2510,11 +2576,12 @@ gem.
|
|
|
2510
2576
|
`chi bundle install model-notes` (or the `dev` profile) installs reviewed
|
|
2511
2577
|
[model notes](memory.md#model-notes) into the system scope: working habits
|
|
2512
2578
|
for one model family, loaded into the prompt only for the models a note's
|
|
2513
|
-
`models:` line names. It has no plugin or hook. It ships
|
|
2579
|
+
`models:` line names. It has no plugin or hook. It ships two notes:
|
|
2514
2580
|
|
|
2515
2581
|
| note | models | for |
|
|
2516
2582
|
|---|---|---|
|
|
2517
2583
|
| `model_notes_deepseek` | `*deepseek-v4*` | DeepSeek V4 / V4.1: explore briefly then act, commit one item at a time, stop digging, `memory_read` for memories, file tools rather than heredocs that guardrail rules ask about |
|
|
2584
|
+
| `model_notes_tone` | `*` | every model: answer first, in plain words; no preambles, restated questions or closing offers; lists only for steps or parallel items |
|
|
2518
2585
|
|
|
2519
2586
|
- **Reviewed before it ships.** A note is generic (no repo or machine facts),
|
|
2520
2587
|
says nothing the harness already does (a tool's description, an alias),
|
|
@@ -5,7 +5,9 @@ require_relative "config"
|
|
|
5
5
|
require_relative "model_profile"
|
|
6
6
|
require_relative "host_registry"
|
|
7
7
|
require_relative "host_keys"
|
|
8
|
+
require_relative "chi_home"
|
|
8
9
|
require_relative "gem_update"
|
|
10
|
+
require_relative "installed_gem"
|
|
9
11
|
require_relative "bootstrap/probe"
|
|
10
12
|
require_relative "bootstrap/config_writer"
|
|
11
13
|
require_relative "bootstrap/bundles"
|
|
@@ -87,8 +89,10 @@ module Samagotchi
|
|
|
87
89
|
# the process ENV points, not +env+)
|
|
88
90
|
# @param gem_runner [#run] runs `gem install commonmarker` (GemUpdate::Runner)
|
|
89
91
|
# @param gem_present [#call, nil] → whether commonmarker is installed
|
|
92
|
+
# @param home [#current, #record_first!] the chi home (ChiHome)
|
|
90
93
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV, probe: nil, config_path: nil,
|
|
91
|
-
platform: RbConfig::CONFIG["host_os"], bundles: nil, gem_runner: GemUpdate::Runner.new, gem_present: nil
|
|
94
|
+
platform: RbConfig::CONFIG["host_os"], bundles: nil, gem_runner: GemUpdate::Runner.new, gem_present: nil,
|
|
95
|
+
gem_spec: InstalledGem.spec, home: ChiHome)
|
|
92
96
|
@argv = argv.dup
|
|
93
97
|
@stdin = stdin
|
|
94
98
|
@stdout = stdout
|
|
@@ -99,8 +103,14 @@ module Samagotchi
|
|
|
99
103
|
@platform = platform
|
|
100
104
|
@bundles = bundles || Bootstrap::Bundles.new
|
|
101
105
|
@gem_runner = gem_runner
|
|
102
|
-
|
|
103
|
-
@
|
|
106
|
+
@gem_spec = gem_spec
|
|
107
|
+
@home = home
|
|
108
|
+
# Installed (in the chi home's gems when there is one: chi web runs
|
|
109
|
+
# there), without loading its native extension into this process.
|
|
110
|
+
@gem_present = gem_present || lambda {
|
|
111
|
+
home = @home.current(gem_spec: @gem_spec)
|
|
112
|
+
home ? home.gem?(MARKDOWN_GEM) : Gem::Specification.find_all_by_name(MARKDOWN_GEM).any?
|
|
113
|
+
}
|
|
104
114
|
end
|
|
105
115
|
|
|
106
116
|
# @return [Integer] 0 written (or already there), 1 failed (a failed
|
|
@@ -371,6 +381,7 @@ module Samagotchi
|
|
|
371
381
|
# each. Then Markdown in chi web (#offer_markdown). 1 when a bundle
|
|
372
382
|
# failed; the Markdown step never changes it.
|
|
373
383
|
def install_bundles(dry_run: false)
|
|
384
|
+
record_home unless dry_run
|
|
374
385
|
ok = system_bundle_line(@bundles.sync_system(dry_run: dry_run), dry_run)
|
|
375
386
|
ok = install_profile(CORE, dry_run) && ok
|
|
376
387
|
unless dry_run || !tty? || (members = dev_to_install).empty?
|
|
@@ -409,13 +420,31 @@ module Samagotchi
|
|
|
409
420
|
false
|
|
410
421
|
end
|
|
411
422
|
|
|
412
|
-
#
|
|
413
|
-
#
|
|
423
|
+
# The first chi home (ChiHome.record_first!: the helper's Ruby, else
|
|
424
|
+
# this one), one line; nothing when one is recorded already.
|
|
425
|
+
def record_home
|
|
426
|
+
recorded = @home.record_first!(gem_spec: @gem_spec, launch_path: helper_launch_path)
|
|
427
|
+
@stdout.puts("chi home: #{recorded.ruby} (chi home set to change)") if recorded
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def helper_launch_path
|
|
431
|
+
return nil unless @platform.to_s.include?("darwin")
|
|
432
|
+
|
|
433
|
+
require_relative "desktop/macos"
|
|
434
|
+
Desktop::MacOS.new(env: @env, register: false).launch_path
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# true when commonmarker is there or `gem install` (the chi home's gem
|
|
438
|
+
# command, else this Ruby's own) put it there; else one line with gem's
|
|
439
|
+
# last word on it.
|
|
414
440
|
def install_markdown_gem
|
|
415
441
|
return true if @gem_present.call
|
|
416
442
|
|
|
417
443
|
@stdout.puts("#{MARKDOWN_GEM}: installing…")
|
|
418
|
-
|
|
444
|
+
home = @home.current(gem_spec: @gem_spec)
|
|
445
|
+
argv = [ChiHome.gem_bin(home), "install", MARKDOWN_GEM, "--no-document"]
|
|
446
|
+
env = ChiHome.install_env(home, env: @env)
|
|
447
|
+
output, ok = env.empty? ? @gem_runner.run(argv) : @gem_runner.run(argv, env: env)
|
|
419
448
|
return true if ok
|
|
420
449
|
|
|
421
450
|
last = output.to_s.lines.map(&:strip).reject(&:empty?).last || "no output"
|
|
@@ -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
|