samagotchi 0.88.0 → 0.90.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f395ab65a9e00861ae1dda52090efbbe363946df8098702f14c41cdc665f7bb3
4
- data.tar.gz: 9f22902c72237fd51481b78f1cc91197fdc474e7f5b816e0a4429d354eb5c0aa
3
+ metadata.gz: 06240ded5e15bc55c7c3467bc7d5c0e79daecd5822291feebf72bbab6e6e6cb7
4
+ data.tar.gz: 52adfdbb519ba9bed3575d55abe737dbca654d81c3c213706a556b56742628af
5
5
  SHA512:
6
- metadata.gz: 2db614ca70a821eb84905eef2d8a8fead4ac4c95b9521cb32417e034fd9f6c923dd97974f25aa2a401d3131fa479ac1911bc8e8f41aa1f383b4e34f925c51078
7
- data.tar.gz: 0a2625e7ce2e7022628f5a1e14c6277eeab21caf21ae4968e908cfe68735be5c39dea2f638137f96b63a0d0ed0796f60bc7c1d3a4e71db87dc453716012957cc
6
+ metadata.gz: 33fdd1b34ce5e0840330451bb90675fe6fb9de6903d3569f3d8e6559e09cad004bcd40bfeec0e0468943d0b2d2abfce8a21d097152c86a53bd2cd332e0386437
7
+ data.tar.gz: 5b19168272d7de67413c480c59bbee363b14a0ba106cdc146632cfb03be45cc646367454758fcbe95aba7562926ab700620e839b2526e818875312f5453d413d
data/CHANGELOG.md CHANGED
@@ -8,6 +8,40 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.90.0] - 2026-10-19
12
+
13
+ ### Changed
14
+
15
+ - SSE `tool_call_started` carries no `call`, `turn_completed` no `result`; read `tool`/`params`/`view` and
16
+ `turn_summary.output`. `?raw=1` on the worker's stream shows them.
17
+ - docs/bundle-authoring.md (the guide chi reads to write a bundle) makes edit → `check --fix` → `test` → `try` the
18
+ loop, with `install --dev .` while iterating and `try` as the live check; its test example runs as written.
19
+
20
+ ### Fixed
21
+
22
+ - `chi -p` followed by one of chi's own flags (`chi -p --non-interactive "text"`, `-p -v`) now exits 2 with a usage
23
+ error instead of taking the flag as the prompt.
24
+
25
+ Update with `chi update` (the system bundle 0.90.0), then restart `chi web`.
26
+
27
+ ## [0.89.0] - 2026-10-19
28
+
29
+ ### Added
30
+
31
+ - `chi bundle try PATH -p TEXT` runs one real model turn in an isolated sandbox with the bundle and shipped
32
+ `guardrails` installed and a $1 cost cap; `--json` exposes an experimental machine-readable trace.
33
+ - `chi bundle install --dev PATH` installs a bundle you are writing as a link to its folder: edits to its plugin,
34
+ hooks, rules and scripts apply at the next session with no re-install, and their sha256 isn't checked (that
35
+ bundle only). Memories are copied; re-install with `--force` to pick up memory edits. A re-install from the same
36
+ PATH prunes dropped hooks. It refuses chi-shipped bundles and profiles and warns when a linked script needs
37
+ `chmod +x`. `upgrade` refuses it; `uninstall` never touches PATH. `chi bundle status` shows `dev → PATH (missing)`
38
+ if the source is gone.
39
+
40
+ ### Changed
41
+
42
+ - minitest is now a dependency of the gem, so `chi bundle test` runs a bundle's Minitest tests (and the one
43
+ `chi bundle new` writes) on a slim or vendored install without `gem install minitest`.
44
+
11
45
  ## [0.88.0] - 2026-10-19
12
46
 
13
47
  ### Added
@@ -3495,7 +3529,9 @@ and long-lived sessions.
3495
3529
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3496
3530
  hotkey panel that send selected text or the clipboard to your sessions.
3497
3531
 
3498
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.88.0...HEAD
3532
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.90.0...HEAD
3533
+ [0.90.0]: https://github.com/dm1try/samagotchi/compare/v0.89.0...v0.90.0
3534
+ [0.89.0]: https://github.com/dm1try/samagotchi/compare/v0.88.0...v0.89.0
3499
3535
  [0.88.0]: https://github.com/dm1try/samagotchi/compare/v0.87.0...v0.88.0
3500
3536
  [0.87.0]: https://github.com/dm1try/samagotchi/compare/v0.86.0...v0.87.0
3501
3537
  [0.86.0]: https://github.com/dm1try/samagotchi/compare/v0.85.0...v0.86.0
data/bin/chi CHANGED
@@ -74,6 +74,7 @@ options = { verbose: false, no_interrupt: Samagotchi::Config.get("no_interrupt")
74
74
  cli_overrides = {}
75
75
 
76
76
  # Strict kebab enforcement: --recap_base_url is unknown (OptionParser would otherwise accept _ as -)
77
+ known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
77
78
  ARGV.each do |arg|
78
79
  next unless arg.start_with?("--") && arg.include?("_")
79
80
 
@@ -82,7 +83,6 @@ ARGV.each do |arg|
82
83
  base = arg.split("=", 2).first
83
84
  # If dashed version is a known flag (registry or hardcoded), reject underscore variant
84
85
  dashed = base.tr("_", "-")
85
- known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
86
86
  if known.include?(dashed) || known.include?(base)
87
87
  warn "Unknown option: #{arg} (did you mean #{dashed}?)"
88
88
  exit 1
@@ -191,8 +191,33 @@ end
191
191
  # value (-p, --model …: the parser's own list) keeps its next word, so
192
192
  # `chi -p "--no-tool=x is broken"` is a prompt. A tool name never starts
193
193
  # with "-": `--no-tool --` or `--no-tool --model` is a missing argument.
194
- takes_value = parser.instance_variable_get(:@stack).flat_map do |list|
195
- list.list.select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }.flat_map { |switch| switch.long + switch.short }
194
+ switches = parser.instance_variable_get(:@stack).flat_map { |list| list.list }
195
+ .select { |switch| switch.is_a?(OptionParser::Switch) }
196
+ switch_flags = switches.flat_map { |switch| switch.long + switch.short }
197
+ known_flags = (known + switch_flags).uniq
198
+ takes_value = switches.select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }
199
+ .flat_map { |switch| switch.long + switch.short }
200
+ reject_prompt_flag = lambda do |flag, value|
201
+ if known_flags.include?(value)
202
+ warn "Error: #{flag} needs the prompt text right after it (got the flag #{value}) (see chi --help)"
203
+ exit 2
204
+ end
205
+ end
206
+ index = 0
207
+ while index < ARGV.size && ARGV[index] != "--"
208
+ arg = ARGV[index]
209
+ if %w[-p --prompt].include?(arg)
210
+ reject_prompt_flag.call(arg, ARGV[index + 1])
211
+ index += 2
212
+ elsif arg.start_with?("--prompt=")
213
+ reject_prompt_flag.call("--prompt", arg.delete_prefix("--prompt="))
214
+ index += 1
215
+ elsif arg.start_with?("-p") && arg != "-p"
216
+ reject_prompt_flag.call("-p", arg.delete_prefix("-p").delete_prefix("="))
217
+ index += 1
218
+ else
219
+ index += takes_value.include?(arg) ? 2 : 1
220
+ end
196
221
  end
197
222
  missing_no_tool = lambda do
198
223
  warn "Error: missing argument: --no-tool (see chi --help)"
data/docs/architecture.md CHANGED
@@ -187,10 +187,15 @@ higher-level events so UIs get clean turn boundaries without inferring them:
187
187
  model's thinking, `0` = never; plus `continue: true` for a continue turn, `resumed: true` for a step-limit Continue,
188
188
  `images:` with the prompt's image refs)
189
189
  - `:turn_completed` — `{ result:, turn_summary:, display_pending: }` (`result`: the final
190
- `LLM::ModelResult`; `display_pending`: `after_turn` hooks may still present the answer)
190
+ `LLM::ModelResult`; Engine event (clients get it without `result`); `display_pending`: `after_turn` hooks may still
191
+ present the answer)
191
192
  - `:turn_canceled` — `{ cancellation_reason:, cancelled_by:, duration_ms: }` (`cancelled_by`: the bundle or hook label that
192
193
  stopped it, for `cancellation_reason: :hook`; else nil)
193
194
 
195
+ These are the Engine's raw events. The worker's SSE projection omits `call` from
196
+ `:tool_call_started` and `result` from `:turn_completed`; the answer remains in
197
+ `turn_summary.output`.
198
+
194
199
  A turn started with `origin:` carries it on each of these (and `:turn_failed`).
195
200
 
196
201
  Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
@@ -246,7 +251,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
246
251
  `session_state_snapshot`. `?snapshot=1` joins with a snapshot frame instead of a replay;
247
252
  `?client_id=` names whose stream it is (`Bridge#open_streams_except`, used by `POST /exit`).
248
253
  Each event goes out as clients get it (`Bridge::ClientEvent.project`, in `SSEWriter#write_event`, live
249
- and replayed alike); only a `generation_chunk` changes, to chi's own format (the chunk contract below).
254
+ and replayed alike); `generation_chunk`, `tool_call_started` and `turn_completed` are projected to the
255
+ contracts below. The ring retains raw events for replay and `?raw=1`.
250
256
  `?raw=1` writes events as the engine emitted them, for debugging on the worker's port: `chi web`'s
251
257
  `/api/sessions/:id/stream` never passes it on (`Web::App#stream_query`).
252
258
  An attached terminal's stream (`EventStream` with `rediscover:`) looks for the session's live
@@ -265,6 +271,22 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
265
271
  "event_seq":812}
266
272
  ```
267
273
 
274
+ A `tool_call_started` frame keeps its row fields but omits the model's `call`:
275
+
276
+ ```
277
+ event: tool_call_started
278
+ data: {"type":"tool_call_started","iteration":2,"call_index":1,"tool":"write",
279
+ "params":"path=notes.txt","event_seq":812}
280
+ ```
281
+
282
+ A `turn_completed` frame omits `result`; `turn_summary.output` carries the answer:
283
+
284
+ ```
285
+ event: turn_completed
286
+ data: {"type":"turn_completed","turn_summary":{"output":"Done."},
287
+ "display_pending":false,"event_seq":813}
288
+ ```
289
+
268
290
  `counts` is `ContextUsage.normalize` of the provider's payload without `ctx_pct` (normalize computes it
269
291
  without the session's LLM context budget; a client counts `total_tokens` against the window or budget, as
270
292
  `ContextStatus#generation_done` does for the TUI) and with its nil keys left out: cumulative for the
@@ -9,15 +9,33 @@ files, what a hook sees, how to test, and what goes wrong. The full references a
9
9
  ```
10
10
  chi bundle new my-bundle # ./my-bundle: manifest.yml, plugin.rb, test/my_bundle_test.rb, README.md
11
11
  cd my-bundle # edit plugin.rb and the test
12
- chi bundle test # the tests, on chi's own Ruby and lib (exit status: the tests')
12
+ chi bundle install --dev . # once while iterating: link this folder; edits load in the next session
13
13
  chi bundle check --fix # manifest, files, shas (--fix rewrites them), requires_chi, install + load in a sandbox
14
- chi bundle install . # into the user's chi; a NEW session loads it (a running one doesn't)
14
+ chi bundle test # tests on chi's own Ruby and lib (exit status: the tests')
15
+ chi bundle try . -p "Say hello" # one real model turn; trace the bundle in an isolated install sandbox
16
+ chi bundle uninstall my-bundle && chi bundle install . # at the end: leave dev mode, copy it in
15
17
  ```
16
18
 
17
- `check` prints one finding per line, `error|warn|info CODE file[:line]: message (fix: hint)`, and exits 1 on any
18
- error. Run it after every edit: an install takes exactly what it checks. It installs into a temp sandbox, not
19
- the user's config or state, but `check` and `test` run the bundle's plugin and hooks in their own process, as
20
- written: run them on code you'd install.
19
+ The loop is **edit → `check --fix` → `test` → `try`**. `install --dev .` links the folder once, so a new session
20
+ loads your edits with no reinstall (a running one doesn't; `try` doesn't need the link: it installs into its own
21
+ sandbox). Memories are copied: re-install with `--force` from the same PATH to pick up memory edits. At the end,
22
+ `uninstall NAME` leaves dev mode and a plain `install .` copies the bundle in (chi refuses it over a dev link).
23
+ Dev-linked scripts aren't chmodded: chi warns with a `chmod +x PATH/scripts/x` hint; set the bit on the source.
24
+
25
+ `check` prints one finding per line (`error|warn|info CODE file[:line]: message (fix: hint)`), exits 1 on any error
26
+ and installs into a temp sandbox, not the user's config or state; run it after every edit. `check` and `test` run
27
+ the bundle's plugin and hooks in their own process, as written: run them on code you'd install.
28
+
29
+ ## Trying a bundle
30
+
31
+ After `chi bundle check --fix`, run `chi bundle try . -p "Say hello"` for one real model call using the user's
32
+ configured hosts and keys. It installs the bundle plus chi's shipped guardrails and a default $1 cost cap, then
33
+ prints tool calls, hook notices, cards, questions, the answer, token totals and cost.
34
+
35
+ The child uses a temporary config, state and working directory; the user's other bundles and memories aren't loaded,
36
+ nor are the current project's `AGENTS.md` / `CLAUDE.md` files. This is not a security sandbox: the bundle and tools
37
+ run with the user's real HOME and account, and can access files and services available there. Use `try` only with
38
+ code you trust. See [CLI: Trying a bundle](cli.md#trying-a-bundle) for flags, `--json`, `--keep` and exit statuses.
21
39
 
22
40
  ## The files
23
41
 
@@ -68,29 +86,36 @@ that list is never fired (`check` warns). The two a bundle wants most, for an `e
68
86
  Match on `result:`, not `output:`. An execute's result is its `stdout:` and `stderr:` blocks (each left out when
69
87
  empty), then `exit: N` (`exit: 0 (no output)` when both were empty); a non-zero exit makes `status:` `"error"`.
70
88
  Act through `ctx`: `ctx.notify(text, level: :warn)`, `ctx.steer(text)` (a line the model reads before its next
71
- step), `ctx.card(…)`, `ctx.stop_turn(reason)`, `ctx.ask_user(…)`, `ctx.data_dir` (the bundle's own folder),
89
+ step), `ctx.card(…)`, `ctx.stop_turn(reason)`, `ctx.ask_user(…)`, `ctx.data_dir` (the bundle's state folder),
72
90
  `ctx.cwd`, `ctx.log`. In a test, each recorded notice, steer or stop names its hook as `"plugin.rb (bundle
73
91
  my-bundle)"`, the plugin's label (a `hooks/x.rb` file's is `"x.rb (bundle my-bundle)"`).
74
92
 
75
93
  ## Testing it
76
94
 
77
- `chi bundle new` writes a Minitest test that passes as written: copy its shape. The kit (`samagotchi/testing`)
78
- loads the bundle's source through chi's own loaders, in a sandbox (temp HOME, config and state), and records what
79
- it did at chi's edges:
95
+ `chi bundle new` writes a Minitest test that passes as written: copy its shape. The kit (`samagotchi/testing`) loads
96
+ the bundle's source through chi's own loaders in a sandbox (temp HOME, config and state) and records what it did:
97
+ `load_bundle`, then `bundle.command` / `bundle.fire`, and the effects in `bundle.notices`. `bundle.ctx.data_dir` and
98
+ `sandbox.state_dir` are that test's temporary state, never the user's (the scaffold's plugin, tested):
80
99
 
81
100
  ```ruby
82
- bundle = load_bundle(BUNDLE, settings: { "greeting" => "hi" }) # in a Samagotchi::Testing::Case test
83
- bundle.command("/my-bundle chi") # → what the command returned
84
- bundle.fire(:after_tool_call, tool: "execute", status: "error", result: "exit: 1 (no output)", call: { … })
85
- bundle.tool_call({ name: "execute", content: "rm -rf /" }).deny? # before_tool_call hooks, through the real gate
86
- bundle.tool("my_tool", path: "a.txt") # a chi.tool, as the model calls it
87
- bundle.notices, bundle.steers, bundle.cards, bundle.stops, bundle.questions # what it did
88
- bundle.answer({ selected: "yes" }); bundle.model_answers << "text" # ask_user / ask_model answers
89
- bundle.http.stub(:get, "https://api.example/x", json: { ok: true }) # ctx.http, never the network
90
- bundle.set(cwd: dir, running: false); sandbox.config(hash); sandbox.fake_executable("gh", "echo '{}'")
91
- bundle.install # installed as chi installs it: warnings, load failures
101
+ require "minitest/autorun"
102
+ require "samagotchi/testing/minitest"
103
+
104
+ class MyBundleTest < Samagotchi::Testing::Case
105
+ def test_command_and_failed_execute_notice
106
+ bundle = load_bundle(File.expand_path("..", __dir__), settings: { "greeting" => "hi" })
107
+ assert_equal "hi, there", bundle.command("/my-bundle")
108
+ bundle.fire(:after_tool_call, tool: "execute", status: "error", call: { name: "execute", content: "false" },
109
+ result: "exit: 1 (no output)", output: "[execute]\nexit: 1 (no output)")
110
+ assert_equal "the command exited 1", bundle.notices.last.text
111
+ assert_equal File.join(sandbox.state_dir, "plugins", bundle.name), bundle.ctx.data_dir
112
+ end
113
+ end
92
114
  ```
93
115
 
116
+ `bundle.tool_call` runs guarded hooks; `bundle.tool` calls a registered tool. `bundle.steers`, `cards`, `stops` and
117
+ `questions` record effects; `bundle.answer` / `model_answers` provide `ask_user` / `ask_model` answers.
118
+ `bundle.http.stub` stubs `ctx.http`; `sandbox.config`, `sandbox.fake_executable` and `bundle.install` cover setup.
94
119
  The full list is in [plugins.md, Testing a bundle](plugins.md#testing-a-bundle-experimental). The kit is
95
120
  experimental (it may change before chi 1.0). If a test needs a fake of chi's own (a stub of a `Samagotchi::`
96
121
  class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather than reaching into chi.
@@ -113,7 +138,7 @@ class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather
113
138
 
114
139
  ## Verifying it live
115
140
 
116
- After `chi bundle install .`, one real session shows it works with the user's chi:
141
+ `chi bundle try . -p "Say hello"` is the live check (trace, plugin, notices); after the final install, check the copy:
117
142
 
118
143
  ```
119
144
  chi send --new --wait --format json --dir /tmp/try -m "/my-bundle chi" # the JSON names the session id
data/docs/cli.md CHANGED
@@ -26,6 +26,9 @@
26
26
  - `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
27
27
  - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
28
28
 
29
+ - `chi bundle try PATH -p TEXT [options]` — install a bundle in a temporary, filtered sandbox, run one real model turn,
30
+ and print its trace (see [Trying a bundle](#trying-a-bundle)).
31
+
29
32
  ### First setup
30
33
 
31
34
  `chi bootstrap TARGET` names the model server and writes the config for it:
@@ -100,8 +103,16 @@ The loop for a bundle you're writing (the whole path: [bundle-authoring.md](bund
100
103
  Bundler env: `test/**/*_test.rb` on Minitest (`-r samagotchi/testing/minitest`), or else `spec/` on RSpec with
101
104
  the kit (`--options /dev/null -r samagotchi/testing/rspec`, as `rake bundles:specs`). `ARGS` go to the runner
102
105
  (`-- -n /name/`). Exit status: the tests'; 1 with no tests; 2 for usage, or when minitest / rspec doesn't load on
103
- chi's Ruby. The tests run the bundle's code as written, with your environment (the kit sandboxes each
104
- test's HOME and XDG dirs, not the process).
106
+ chi's Ruby (minitest is one of chi's gems; rspec is optional). The tests run the bundle's code as written, with
107
+ your environment (the kit sandboxes each test's HOME and XDG dirs, not the process).
108
+ - `chi bundle install --dev PATH` installs the bundle in the local directory `PATH` (not a git URL, archive or name:
109
+ exit 2) as a link to it: its plugin, hooks, rules and scripts are `PATH`'s own files, so an edit applies at the
110
+ next worker or `chi web` restart with no re-install, and their sha256 isn't checked, for this bundle only (the
111
+ record says `dev`; every other bundle is checked as before). Its memories are copied at install. `chi bundle
112
+ status` shows `NAME vX dev → /abs/path`. `chi bundle upgrade NAME` refuses it (it follows its source already; to
113
+ leave dev mode: `chi bundle uninstall NAME`, then `chi bundle install` without `--dev`), as does a plain install
114
+ over it, and `--dev` over a normal install. `uninstall` removes the links and the record, never `PATH`'s files.
115
+ `check PATH` and `test PATH` are unchanged.
105
116
  - `chi bundle check [PATH] [--fix] [--set KEY=VALUE]` (PATH defaults to `.`) checks it as an install takes it, one
106
117
  finding per line, `error|warn|info CODE file[:line]: message (fix: hint)`, then `ok|failed: N errors, N
107
118
  warnings`. Exit 1 on any error, 0 otherwise, 2 for usage. It installs and loads the bundle in a temp sandbox
@@ -120,6 +131,34 @@ The loop for a bundle you're writing (the whole path: [bundle-authoring.md](bund
120
131
  exceptions aside) and `tests` (info: how to run them, or "no tests"). `--set KEY=VALUE` (repeatable, typed as
121
132
  `chi --set`) goes to `bundles: <name>:` in the sandbox's config.yml.
122
133
 
134
+ ### Trying a bundle
135
+
136
+ `chi bundle try PATH -p TEXT [--model NAME] [--memory NAME] [--mute NAME] [--toolkit NAME] [--thinking LEVEL]
137
+ [--set KEY=VALUE] [--json] [--keep] [--timeout SECONDS]` installs and runs the bundle in a temporary config, state
138
+ and work directory, then prints a trace of tool calls, notices, questions, the answer, token totals and cost. The
139
+ child's working directory is outside your repository, so project memories and `AGENTS.md` / `CLAUDE.md` files in
140
+ your current directory aren't loaded. Your hosts, API-key environment variables, Keychain and other runtime
141
+ settings are retained, but your configured bundles, hooks, guardrails, memories, appended system prompts and
142
+ attached context sources are not copied. Chi installs its shipped `guardrails` and `cost-cap` bundles for the run;
143
+ the default cap is $1 and `--set cost-cap.cap=N` changes it. Other `--set KEY=VALUE` entries are typed config
144
+ values under `bundles: <tried-bundle>:`; unlike `chi --set BUNDLE.KEY=VALUE`, they are not session settings and
145
+ aren't checked against `session_settings:`. `--memory` and `--mute` accept comma-separated names and repeat.
146
+ `--timeout` defaults to 300 seconds. An ask that no one can answer or a guardrail denial is traced and warned
147
+ about without changing the child's exit status; hook questions made with `ctx.ask_user` are invisible in
148
+ non-interactive mode. `--keep` retains the temporary sandbox and prints its path and debug log. `--json` writes
149
+ one experimental JSON object to stdout and human trace lines to stderr; its keys are `session_id`, `exit_code`,
150
+ `answer`, `tools` (`index`, `name`, `args`, `status`, `duration_ms`), `notices`, `cards`, `questions`,
151
+ `interactive_blocked`, `used_memories`, `tokens` (`prompt`, `completion`), `cost`, `cost_is_estimate`, `model`,
152
+ `sandbox`, `denied` (1-based tool indexes) and `stderr_tail` (last 4,000 characters). Exit status: 2 for usage,
153
+ 1 for install/turn failure or timeout, 130 for Ctrl-C, otherwise the child's status.
154
+
155
+ `try` runs the bundle's code and the model's tools with your real HOME, account and keys. Its temporary working
156
+ directory and filtered config do not make untrusted code safe: a plugin, hook or model tool can still access files
157
+ and services available to your account. Use it only for code you trust.
158
+
159
+ Tool arguments are capped to 60 characters in the text trace and 200 in JSON; the text trace shows at most 10 answer
160
+ lines, while JSON includes the full answer. Either may contain secrets.
161
+
123
162
  ### Listing the models
124
163
 
125
164
  `chi models` asks every host in `hosts:` for its models, in parallel, and prints the names `--model` (and
@@ -24,6 +24,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
24
24
  |---|---|
25
25
  | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash/new/test/check`; runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it) |
26
26
  | `BundleCommand::Check` (bundle_command/check.rb) | `chi bundle check [PATH] [--fix] [--set K=V]`: a bundle being written, checked as an install takes it (docs/cli.md lists the codes); parses the manifest from `Shas.parseable` (a placeholder sha is a `sha` finding, not a YAML error), reads the user's installed bundles only for `clash`, and installs and loads through `Testing::Install` in a `Testing::Sandbox` with `force: true`, the log's echoes swallowed |
27
+ | `BundleCommand::Try` (`bundle_command/try/`) | `chi bundle try PATH -p TEXT`: filtered-config `Sandbox` installs the tried bundle and shipped `guardrails` / `cost-cap` in a separate child, then `Run` starts one non-interactive chi process from its realpath work dir; `TraceReader` reads that session's analytics, cards and session JSON by id (never `Session.list`), joining timing and tool args for text or experimental JSON output. `Sandbox#child_env` is a Hash, never an ENV mutation; it keeps ordinary user model settings and Keychain access while dropping the spawner's session/guardrail/worker markers. |
27
28
  | `BundleCommand::Scaffold` (bundle_command/scaffold.rb) | `chi bundle new NAME`: the starting files (block-form `plugin:` entry, `requires_chi` at this chi, a kit test that passes as written); the manifest's shas are filled through `Shas.refresh` |
28
29
  | `BundleCommand::TestRun` (bundle_command/test_run.rb) | `chi bundle test`: `RbConfig.ruby -I <chi's lib>` on `test/**/*_test.rb` (Minitest) or `spec/` (RSpec, as `rake bundles:specs`), under `Bundler.with_unbundled_env` when Bundler's runtime is loaded |
29
30
  | `Shas` (shas.rb) | a manifest's `sha256:` lines: `.refresh(dir[, text])` rewrites them line by line (the layout kept; a `files:` entry it can't place raises `Shas::Error`), `.stale` → `[Stale(file, old, new)]` (`old` `""` for a placeholder, nil for a plugin / hook / script entry with no sha line), `.parseable` (a placeholder `sha256: sha256:` isn't YAML). Plain Ruby: `rake bundles:sha` / `bundles:check` (`script/release/release_tools.rb`) and `chi bundle check --fix` share it |
@@ -45,6 +46,15 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
45
46
  | `IndexSize` | one scope's memory index as the prompt carries it (tokens, lines, bytes): `memory.index_warn_tokens`' crossing note after a memory write, `chi self`'s summary, the prompt's figures (`SystemPrompt`) |
46
47
  | `Placeholder` | `{{placeholder}}` patterns left in an installed memory, one line per file from `Installer#detect_placeholders`; a hint, never a gate |
47
48
 
49
+ A dev install (`chi bundle install --dev PATH`, `Installer#initialize(dev: true)`) links instead of copying:
50
+ `AssetInstaller#place` symlinks each hook, rule file, plugin and script into `.bundles/<name>/` to its realpath in
51
+ the source (memories are still copied: their bases and the index are the install's), and the record gets `"dev": true`
52
+ (`InstalledBundle#dev`, `source` the realpath) with `Provenance::DEV_SHA` (`sha256:dev`, never a hex digest) in
53
+ place of every hook, rule, plugin and script sha256. `Provenance.sha_matches?` / `.unchecked?` read it as "not
54
+ checked", which is how the readers below skip it for that bundle only. `Installer#refuse_dev_mismatch` keeps a dev
55
+ and a normal install from replacing each other (this is also why `chi bundle upgrade` refuses a dev bundle);
56
+ `Uninstaller` removes links with `rm_rf`/`rm_f`, which never follow them into the source.
57
+
48
58
  Readers of the installed state: `Hooks::BundleLoader` (Engine), `GuardrailWiring#bundle_rules`, `Plugin::Loader`,
49
59
  `ContextProviders` (scripts), `BundleNeeds` (index markers). Each goes through `Provenance.each_installed` / `#record`
50
60
  and checks the recorded sha256 (and `requires_chi`) before loading.
@@ -489,12 +489,12 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
489
489
  | `:thinking_refused` / `:reasoning_history_refused` | the chat loop asked a step again without the thinking fields / the reasoning sent back; the Engine logs it and makes a once-per-session-and-host `:hook_notice` of it, the event itself never reaches the sink | `{iteration, model, level, detail}` |
490
490
  | `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
491
491
  | `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
492
- | `:tool_call_started` | before a call runs | see ToolRunner |
492
+ | `:tool_call_started` | before a call runs | Engine event includes `call` (see ToolRunner); the Bridge's SSE frame omits it |
493
493
  | `:tool_call_completed` | after a call ran | see ToolRunner |
494
494
  | `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
495
495
  | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
496
496
  | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
497
- | `:turn_completed` | normal end | `{result, turn_summary, display_pending}` |
497
+ | `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
498
498
  | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
499
499
  | `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
500
500
  | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
@@ -10,8 +10,10 @@ module Samagotchi
10
10
  # the provider's raw `payload`, and no raw `content` (the chat loop's is
11
11
  # reasoning + content again, the native loop's the raw stream with its
12
12
  # tool-call bytes and thought tags): clients read `text`, `thinking`,
13
- # `tool_call` and `counts`, never a provider's payload shape. `?raw=1` on
14
- # the worker's stream skips this (debugging).
13
+ # `tool_call` and `counts`, never a provider's payload shape.
14
+ # :tool_call_started omits the model's `call`; clients render its row from
15
+ # the other fields. :turn_completed omits `result`; `turn_summary.output`
16
+ # carries the answer. `?raw=1` on the worker's stream skips this (debugging).
15
17
  #
16
18
  # Pure: the event is the one hash SessionObserver hands every observer
17
19
  # (the ring, TurnAccumulator, the other writers, SessionMetrics, hooks),
@@ -28,13 +30,19 @@ module Samagotchi
28
30
  # @param event [Hash] the engine's event (symbol keys; its payload has
29
31
  # string keys)
30
32
  # @return [Hash] the event as clients get it: a copy for a
31
- # :generation_chunk, the same object for every other event
33
+ # projected event, the same object for every other event
32
34
  def project(event)
33
- return event unless event.is_a?(Hash) && event[:type].to_s == "generation_chunk"
35
+ return event unless event.is_a?(Hash)
34
36
 
35
- projected = event.except(:payload, :content)
36
- found = counts(event[:payload])
37
- found ? projected.merge(counts: found) : projected
37
+ case event[:type].to_s
38
+ when "generation_chunk"
39
+ projected = event.except(:payload, :content)
40
+ found = counts(event[:payload])
41
+ found ? projected.merge(counts: found) : projected
42
+ when "tool_call_started" then event.except(:call)
43
+ when "turn_completed" then event.except(:result)
44
+ else event
45
+ end
38
46
  end
39
47
 
40
48
  # The payload's normalized counts, nil keys left out; nil when the
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # *_test.rb runs on Minitest with the kit's Case; a spec/ with no test/
11
11
  # on RSpec with the kit's helpers, as `rake bundles:specs` runs the
12
12
  # shipped ones. Outside any Bundler env (Bundler.with_unbundled_env, as
13
- # SessionManager's unbundled_env_changes): chi's gems don't include minitest.
13
+ # InstalledGem.unbundled_env_changes), where chi's own gem dependency minitest loads.
14
14
  # When the framework loads only inside the Bundler env chi itself runs in
15
15
  # (a checkout or a vendored install: its gems in vendor/bundle), there.
16
16
  # The tests' exit status is #run's.
@@ -37,7 +37,7 @@ module Samagotchi
37
37
  private
38
38
 
39
39
  def minitest(tests)
40
- return missing("minitest", "gem install minitest") unless loads?("minitest")
40
+ return missing("minitest", "it ships with chi: reinstall chi (gem install samagotchi)") unless loads?("minitest")
41
41
 
42
42
  requires = tests.map { |file| "require #{file.inspect}" }.join("; ")
43
43
  say("#{tests.size} test file#{"s" unless tests.size == 1}")
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+
6
+ module Samagotchi
7
+ class BundleCommand
8
+ module Try
9
+ class Run
10
+ Result = Data.define(:exit_status, :stdout, :stderr, :session_id, :timed_out)
11
+ INTERRUPT_GRACE = 5
12
+ READER_GRACE = 2
13
+ GROUP_TERM_GRACE = 0.1
14
+ POLL_INTERVAL = 0.05
15
+
16
+ def initialize(sandbox)
17
+ @sandbox = sandbox
18
+ end
19
+
20
+ def run(prompt:, options:, timeout:)
21
+ command = [RbConfig.ruby, "-I", Sandbox::LIB_DIR, Sandbox::CHI,
22
+ "--no-shared", "--non-interactive", "-p", prompt, *child_options(options)]
23
+ trap_state, old_traps = trap_forwarding
24
+ pid = nil
25
+ streams = []
26
+ readers = []
27
+ begin
28
+ Open3.popen3(@sandbox.child_env, *command, chdir: @sandbox.work_dir, pgroup: true, in: File::NULL) do |stdin, stdout, stderr, wait|
29
+ pid = wait.pid
30
+ streams = [stdout, stderr]
31
+ stdin.close
32
+ readers = streams.map { |stream| Thread.new { read_stream(stream) } }
33
+ attach_child(trap_state, pid)
34
+ timed_out = false
35
+ status = wait_for(wait, timeout)
36
+ if status == :timeout
37
+ timed_out = true
38
+ signal_group(pid, "INT")
39
+ status = wait_for(wait, INTERRUPT_GRACE)
40
+ if status == :timeout
41
+ terminate_group(pid)
42
+ status = wait.value
43
+ end
44
+ end
45
+ out, err = finish_readers(readers, streams)
46
+ code = timed_out ? CLI::Exit::FAILED : process_exit_status(status)
47
+ Result.new(exit_status: code, stdout: out, stderr: err, session_id: err[/^Session: (\S+)/, 1],
48
+ timed_out: timed_out)
49
+ end
50
+ rescue SystemCallError => e
51
+ raise Sandbox::Error, "chi bundle try: couldn't run chi: #{e.message}"
52
+ ensure
53
+ terminate_group(pid) if pid
54
+ close_streams(streams)
55
+ stop_readers(readers)
56
+ restore_traps(old_traps)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def child_options(options)
63
+ args = []
64
+ %i[model toolkit thinking].each do |key|
65
+ args.concat(["--#{key}", options[key]]) if options[key]
66
+ end
67
+ %i[memory mute].each do |key|
68
+ Array(options[key]).each { |value| args.concat(["--#{key}", value]) }
69
+ end
70
+ args
71
+ end
72
+
73
+ def wait_for(wait, seconds)
74
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
75
+ loop do
76
+ return wait.value if wait.join(POLL_INTERVAL)
77
+ return :timeout if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
78
+ end
79
+ end
80
+
81
+ def trap_forwarding
82
+ state = { pid: nil, pending: nil }
83
+ old = {}
84
+ %w[INT TERM].each do |signal|
85
+ old[signal] = Signal.trap(signal) do
86
+ if state[:pid]
87
+ signal_group(state[:pid], signal)
88
+ else
89
+ state[:pending] ||= signal
90
+ end
91
+ end
92
+ end
93
+ [state, old]
94
+ end
95
+
96
+ def attach_child(state, pid)
97
+ state[:pid] = pid
98
+ pending = state[:pending]
99
+ state[:pending] = nil
100
+ signal_group(pid, pending) if pending
101
+ end
102
+
103
+ def restore_traps(old)
104
+ old.each { |signal, handler| Signal.trap(signal, handler) }
105
+ end
106
+
107
+ def signal_group(pid, signal)
108
+ Process.kill(signal, -pid)
109
+ rescue Errno::ESRCH
110
+ nil
111
+ end
112
+
113
+ def terminate_group(pid)
114
+ signal_group(pid, "TERM")
115
+ sleep(GROUP_TERM_GRACE)
116
+ signal_group(pid, "KILL")
117
+ end
118
+
119
+ def read_stream(stream)
120
+ output = +""
121
+ loop { output << stream.readpartial(16_384) }
122
+ rescue EOFError
123
+ output
124
+ rescue IOError
125
+ raise unless stream.closed?
126
+
127
+ output
128
+ end
129
+
130
+ def finish_readers(readers, streams)
131
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + READER_GRACE
132
+ readers.each do |reader|
133
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+ reader.join([remaining, 0].max)
135
+ end
136
+ close_streams(streams)
137
+ readers.each do |reader|
138
+ reader.join(0.1) if reader.alive?
139
+ reader.kill if reader.alive?
140
+ end
141
+ readers.map { |reader| reader.value.to_s }
142
+ end
143
+
144
+ def close_streams(streams)
145
+ streams.each { |stream| stream.close unless stream.closed? }
146
+ end
147
+
148
+ def stop_readers(readers)
149
+ readers.each do |reader|
150
+ reader.join(0.1)
151
+ reader.kill if reader.alive?
152
+ end
153
+ readers.each(&:join)
154
+ end
155
+
156
+ def process_exit_status(status)
157
+ status.exitstatus || (128 + status.termsig.to_i)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end