samagotchi 0.87.0 → 0.89.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 +35 -1
- data/docs/bundle-authoring.md +19 -0
- data/docs/cli.md +44 -5
- data/docs/guardrails.md +6 -1
- data/docs/internals/bundles.md +10 -0
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +9 -0
- data/lib/samagotchi/bundle_command/test_run.rb +2 -2
- data/lib/samagotchi/bundle_command/try/run.rb +162 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +159 -0
- data/lib/samagotchi/bundle_command/try/trace.rb +152 -0
- data/lib/samagotchi/bundle_command.rb +216 -10
- data/lib/samagotchi/bundle_session_settings.rb +2 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +100 -7
- data/lib/samagotchi/context_providers.rb +1 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +20 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +2 -0
- data/lib/samagotchi/installed_gem.rb +11 -0
- data/lib/samagotchi/memory_bundle/asset_installer.rb +25 -13
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +22 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +21 -6
- data/lib/samagotchi/relay.rb +34 -2
- data/lib/samagotchi/relay_desk.rb +13 -0
- data/lib/samagotchi/session_manager.rb +2 -10
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +69 -15
- data/lib/samagotchi/tools/delegate_wait.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/question_card.js +2 -0
- data/lib/samagotchi/web/public/question_cards.js +25 -18
- data/lib/samagotchi/worker.rb +51 -9
- metadata +24 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7e58a54e40f49c1617636172def63d9e6a3eaa3384ac33e26c0b55c75332555
|
|
4
|
+
data.tar.gz: 063a36d57a613a117e62b2882883c22e1ce413044c1e6d18e66aa1c8af0d6d83
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 879572f97fd05fdff6f15d4c91c3f3ce30d480ff0022b1ad3fb0220423c124004c67908f97e85ad6309302720671938cc90da80c6315de0134c9240a9b477feb
|
|
7
|
+
data.tar.gz: 0faf651033f823e4eadc87926d4c370e3e8eb714a4f8106d735110bfe7c8517972a70a4e960448ccda3b8ac1679c796e93b587070255803eb57e24e8c984a2b1
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,38 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.89.0] - 2026-10-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `chi bundle try PATH -p TEXT` runs one real model turn in an isolated sandbox with the bundle and shipped
|
|
16
|
+
`guardrails` installed and a $1 cost cap; `--json` exposes an experimental machine-readable trace.
|
|
17
|
+
- `chi bundle install --dev PATH` installs a bundle you are writing as a link to its folder: edits to its plugin,
|
|
18
|
+
hooks, rules and scripts apply at the next session with no re-install, and their sha256 isn't checked (that
|
|
19
|
+
bundle only). Memories are copied; re-install with `--force` to pick up memory edits. A re-install from the same
|
|
20
|
+
PATH prunes dropped hooks. It refuses chi-shipped bundles and profiles and warns when a linked script needs
|
|
21
|
+
`chmod +x`. `upgrade` refuses it; `uninstall` never touches PATH. `chi bundle status` shows `dev → PATH (missing)`
|
|
22
|
+
if the source is gone.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- minitest is now a dependency of the gem, so `chi bundle test` runs a bundle's Minitest tests (and the one
|
|
27
|
+
`chi bundle new` writes) on a slim or vendored install without `gem install minitest`.
|
|
28
|
+
|
|
29
|
+
## [0.88.0] - 2026-10-19
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- A `delegate` child's hook question (the cost cap's Continue/Stop, a bundle's `ctx.ask_user`) now reaches its parent,
|
|
34
|
+
in the background too: reported to the parent's model when it may pick something other than a deny under the
|
|
35
|
+
child's `parent_answers` (so under the default `soft` a background parent may Continue its child's cost cap), else
|
|
36
|
+
relayed to the parent's user as a card, as approvals are.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- A session file whose `parent_answers` isn't `hard`, `standard` or `soft` (a hand edit) now gets one warning when
|
|
41
|
+
its worker loads it, instead of being read as `hard` silently.
|
|
42
|
+
|
|
11
43
|
## [0.87.0] - 2026-10-19
|
|
12
44
|
|
|
13
45
|
### Added
|
|
@@ -3481,7 +3513,9 @@ and long-lived sessions.
|
|
|
3481
3513
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3482
3514
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3483
3515
|
|
|
3484
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3516
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.89.0...HEAD
|
|
3517
|
+
[0.89.0]: https://github.com/dm1try/samagotchi/compare/v0.88.0...v0.89.0
|
|
3518
|
+
[0.88.0]: https://github.com/dm1try/samagotchi/compare/v0.87.0...v0.88.0
|
|
3485
3519
|
[0.87.0]: https://github.com/dm1try/samagotchi/compare/v0.86.0...v0.87.0
|
|
3486
3520
|
[0.86.0]: https://github.com/dm1try/samagotchi/compare/v0.85.0...v0.86.0
|
|
3487
3521
|
[0.85.0]: https://github.com/dm1try/samagotchi/compare/v0.84.0...v0.85.0
|
data/docs/bundle-authoring.md
CHANGED
|
@@ -11,14 +11,33 @@ chi bundle new my-bundle # ./my-bundle: manifest.yml, plugin.rb, test/my_bun
|
|
|
11
11
|
cd my-bundle # edit plugin.rb and the test
|
|
12
12
|
chi bundle test # the tests, on chi's own Ruby and lib (exit status: the tests')
|
|
13
13
|
chi bundle check --fix # manifest, files, shas (--fix rewrites them), requires_chi, install + load in a sandbox
|
|
14
|
+
chi bundle try . -p "Say hello" # one real model turn; trace the bundle in an isolated install sandbox
|
|
14
15
|
chi bundle install . # into the user's chi; a NEW session loads it (a running one doesn't)
|
|
16
|
+
chi bundle install --dev . # or as a link to this folder: edits apply at the next session, no re-install
|
|
15
17
|
```
|
|
16
18
|
|
|
19
|
+
While you write it, `install --dev .` (once; `uninstall NAME` leaves dev mode) then try it in chi: a new session
|
|
20
|
+
loads the plugin and hooks from the folder as they are, with no sha check (that bundle only; memories are copied).
|
|
21
|
+
Re-install with `--force` from the same PATH to pick up memory edits.
|
|
22
|
+
Dev-linked scripts aren't chmodded, since that would change the source. Chi warns with a `chmod +x PATH/scripts/x`
|
|
23
|
+
hint when a listed script isn't executable; set that bit on the source script.
|
|
24
|
+
|
|
17
25
|
`check` prints one finding per line, `error|warn|info CODE file[:line]: message (fix: hint)`, and exits 1 on any
|
|
18
26
|
error. Run it after every edit: an install takes exactly what it checks. It installs into a temp sandbox, not
|
|
19
27
|
the user's config or state, but `check` and `test` run the bundle's plugin and hooks in their own process, as
|
|
20
28
|
written: run them on code you'd install.
|
|
21
29
|
|
|
30
|
+
## Trying a bundle
|
|
31
|
+
|
|
32
|
+
After `chi bundle check --fix`, run `chi bundle try . -p "Say hello"` for one real model call using the user's
|
|
33
|
+
configured hosts and keys. It installs the bundle plus chi's shipped guardrails and a default $1 cost cap, then
|
|
34
|
+
prints tool calls, hook notices, cards, questions, the answer, token totals and cost.
|
|
35
|
+
|
|
36
|
+
The child uses a temporary config, state and working directory; the user's other bundles and memories aren't loaded,
|
|
37
|
+
nor are the current project's `AGENTS.md` / `CLAUDE.md` files. This is not a security sandbox: the bundle and tools
|
|
38
|
+
run with the user's real HOME and account, and can access files and services available there. Use `try` only with
|
|
39
|
+
code you trust. See [CLI: Trying a bundle](cli.md#trying-a-bundle) for flags, `--json`, `--keep` and exit statuses.
|
|
40
|
+
|
|
22
41
|
## The files
|
|
23
42
|
|
|
24
43
|
```yaml
|
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
|
|
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
|
|
@@ -491,9 +530,9 @@ file and index line stay as they are; only this session doesn't see it.
|
|
|
491
530
|
- Names are checked before the launch, warnings only: `Warning: --memory 'x'
|
|
492
531
|
not found`, `Warning: --mute 'x' matches no memory`, `Warning: 'x' is both
|
|
493
532
|
--memory and --mute; muted`.
|
|
494
|
-
- `--attach ID` or `--resume ID` with either flag: the session
|
|
495
|
-
|
|
496
|
-
`(--mute applies to a new session; <id>
|
|
533
|
+
- `--attach ID` or `--resume ID` with either flag: the session keeps its
|
|
534
|
+
own, so the flags are ignored with one line,
|
|
535
|
+
`(--mute applies to a new session; <id> keeps its own)`.
|
|
497
536
|
- The status row shows `mem: <used and preloaded>` and `muted: <names>`; the
|
|
498
537
|
Web UI's info-bar tooltip shows `memories: … · preloaded: … · muted: …`.
|
|
499
538
|
- The `read` tool on `memories/<name>.md` is not refused (a guardrails rule
|
data/docs/guardrails.md
CHANGED
|
@@ -103,7 +103,12 @@ The file tools can't write the store.
|
|
|
103
103
|
### Answers from a parent agent
|
|
104
104
|
|
|
105
105
|
A chi parent's `delegate` children don't need any of this for approvals: they
|
|
106
|
-
go to the user on the parent's own card (the approval relay)
|
|
106
|
+
go to the user on the parent's own card (the approval relay), as do the hook
|
|
107
|
+
questions (the cost cap's Continue/Stop, a bundle's `ctx.ask_user`) where the
|
|
108
|
+
parent may pick nothing but a deny under the child's `parent_answers`; the
|
|
109
|
+
others come back to the parent's model with the block below. A dismissal
|
|
110
|
+
always passes (the hook decides what no answer means): the strategy is a
|
|
111
|
+
convention for an honest parent, not a barrier. This section is
|
|
107
112
|
the path for other agents, and for a chi parent that can't host the relay (a
|
|
108
113
|
`--no-shared` REPL, `--non-interactive`): there the model gets the question
|
|
109
114
|
block and the rules below. With the relay, the same rules still hold for a
|
data/docs/internals/bundles.md
CHANGED
|
@@ -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.
|
data/docs/sessions.md
CHANGED
|
@@ -278,13 +278,13 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
278
278
|
- A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
|
|
279
279
|
- What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
|
|
280
280
|
- **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `guardrails.parent_answers`, or the child's own when stricter (`hard`: Stop only; see [chi as a sub-agent](sub-agent.md#the-step-limit)).
|
|
281
|
-
- **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap"
|
|
282
|
-
- **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
|
|
281
|
+
- **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap", `Continue` or `Stop`. Like every hook's question on a `delegate` child, it goes where it can be decided, under the child's `guardrails.parent_answers` (config.yml's, or the child's own when stricter): when the parent may pick an option other than a deny (Stop) there, `standard` and `soft` here, it comes back to the parent's model as `status: question` with the `chi answer` commands; when it may not, `hard` here (a parent's Continue is refused), it goes to the parent's user as a card, as approvals do (the relay below), and the model sees one line: `hook question relayed to your user: Cost cap → Stop` (or `dismissed`, which the hook reads as it likes, cost-cap as Stop; `refused by the child …`, `answered on the child`, `still open (your turn was stopped)`). Free text alone doesn't count as an option the parent may pick. A **background** child (`wait: false`) paused at its cap rings its parent as a question does (delegate reports, below): the parent's model gets it as a report when it may answer; otherwise the parent's worker relays it to its user at its next step boundary, or in a wake turn's first boundary when idle (before the model is called), and the report the model gets is the outcome line. While that card is open the parent's turn waits on it; a Stop closes it and leaves the question for your next input (or a `delegate_result`): no wake turn offers that question again meanwhile, while other children's reports and attached-context wakes go on. Under the default `soft` that means a background parent's model now hears its child's cost cap, and may Continue it.
|
|
282
|
+
- **A child's approvals go to the parent's user** (the approval relay), and so do the hook questions its parent can't usefully answer (above: a card headed `delegate ab12 asks: Cost cap`, the child's options and kind, no approval facts). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
|
|
283
283
|
- The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
|
|
284
284
|
- How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_answers` (each session's own, when stricter) by the parent and again by the child.
|
|
285
285
|
- A Stop of the parent's turn closes its card; the child's approval stays open there. A child's worker that goes closes the card (`… worker is gone`); a parent's worker that goes clears the child's "waiting in parent" line within 2 s. A later `delegate_result` relays it again.
|
|
286
|
-
- No relay where the parent can't host one (a `--no-shared` REPL, `--non-interactive`) or isn't waiting (`wait: false` until the next `delegate_result
|
|
287
|
-
- **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent
|
|
286
|
+
- No relay where the parent can't host one (a `--no-shared` REPL, `--non-interactive`) or, for an approval, isn't waiting (`wait: false` until the next `delegate_result`; a hook's question it may only deny is relayed from a delegate report instead, above): the approval waits on the child, with the web's bell, and the result's question block says to deny it or leave it for the user. The model's own questions, and the hooks' questions the parent may answer, come back to the model as before. A hook's question already answered on one of the parent's relay cards (a refused answer: it waits for the user on the child) is not relayed again; one whose card closed (a Stop, the child gone) is, and an approval always is, as before. A grandchild's (a plugin's `ctx.sessions.fork` can make one) is relayed hop by hop: `delegate ab12 → cd34 asks:`.
|
|
287
|
+
- **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent decides (the child's own `ask_user_question`, the step-limit continue, a hook's question, relayed to the parent's user first when the parent may only deny it; not an approval, which the relay or the user on the child's card answers, nor a card the child relays from its own child), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
|
|
288
288
|
- Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
|
|
289
289
|
- Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). An archived parent that a later link continues wakes that link instead, which takes the rings its moved delegates left there (see *Session chains*). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
|
|
290
290
|
- The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
|
data/docs/sub-agent.md
CHANGED
|
@@ -13,6 +13,15 @@ worker keeps the turn paused. The parent answers with `chi answer`, from what it
|
|
|
13
13
|
knows or after asking its own user, and chi goes on from there. The model gets
|
|
14
14
|
the answer as its tool result, as if you had answered in the web.
|
|
15
15
|
|
|
16
|
+
A chi parent's own `delegate` children work the same way, with one shortcut:
|
|
17
|
+
their approvals, and the hook questions the parent may only deny under the
|
|
18
|
+
child's `guardrails.parent_answers`, go to the parent's user as a card in the
|
|
19
|
+
parent's UIs instead (the relay, [Sessions](sessions.md#delegating)). When an
|
|
20
|
+
agent runs that parent with `chi send --wait`, the relay card is the parent's
|
|
21
|
+
own question: exit 3 with its text (`delegate ab12 … asks:`), options and
|
|
22
|
+
`answer_with`; the JSON has no `relay` key. Its `chi answer` is held to the
|
|
23
|
+
parent's setting, then to the child's.
|
|
24
|
+
|
|
16
25
|
The other direction, chi sending a part of its work to a Claude Code session
|
|
17
26
|
you already work with (to validate, as context, or with a question), is the
|
|
18
27
|
[claude-peer bundle](plugins.md#the-claude-peer-bundle): Claude pairs with 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
|
-
#
|
|
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
|
|
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
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "yaml"
|
|
8
|
+
require_relative "../../config"
|
|
9
|
+
require_relative "../../cli/exit"
|
|
10
|
+
require_relative "../../installed_gem"
|
|
11
|
+
require_relative "../../memory_bundle"
|
|
12
|
+
require_relative "../../memory_bundle/shas"
|
|
13
|
+
require_relative "../../session_manager"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
class BundleCommand
|
|
17
|
+
module Try
|
|
18
|
+
class Sandbox
|
|
19
|
+
LIB_DIR = File.expand_path("../../..", __dir__)
|
|
20
|
+
CHI = File.expand_path("../../../../bin/chi", __dir__)
|
|
21
|
+
SHIPPED_DIR = MemoryBundle::SourceNormalizer::SHIPPED_DIR
|
|
22
|
+
CONFIG_DENYLIST = %w[
|
|
23
|
+
bundles hooks guardrails memories append_system_prompt recap session log web update kitty tmux broadcast
|
|
24
|
+
context_sources
|
|
25
|
+
].freeze
|
|
26
|
+
CONFIG_DENYLIST_ENV_PREFIXES = CONFIG_DENYLIST.map do |section|
|
|
27
|
+
prefix = if section == "guardrails"
|
|
28
|
+
SessionManager::GUARDRAILS_ENV_PREFIX.delete_suffix("_")
|
|
29
|
+
else
|
|
30
|
+
"SAMAGOTCHI_#{section.upcase}"
|
|
31
|
+
end
|
|
32
|
+
prefix
|
|
33
|
+
end.freeze
|
|
34
|
+
class Error < StandardError
|
|
35
|
+
attr_reader :status
|
|
36
|
+
|
|
37
|
+
def initialize(message, status: CLI::Exit::FAILED)
|
|
38
|
+
super(message)
|
|
39
|
+
@status = status
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :root, :config_home, :state_home, :work_dir, :manifest
|
|
44
|
+
|
|
45
|
+
def initialize(path, bundle_settings: {}, cost_cap: 1, cost_cap_override: false)
|
|
46
|
+
@path = File.realpath(path)
|
|
47
|
+
@bundle_settings = bundle_settings
|
|
48
|
+
@cost_cap = cost_cap
|
|
49
|
+
@cost_cap_override = cost_cap_override
|
|
50
|
+
read_manifest
|
|
51
|
+
@root = File.realpath(Dir.mktmpdir("chi-bundle-try-"))
|
|
52
|
+
@config_home = File.join(root, "config")
|
|
53
|
+
@state_home = File.join(root, "state")
|
|
54
|
+
@work_dir = File.join(root, "work")
|
|
55
|
+
[config_home, state_home, work_dir].each { |dir| FileUtils.mkdir_p(dir) }
|
|
56
|
+
@work_dir = File.realpath(@work_dir)
|
|
57
|
+
write_config
|
|
58
|
+
@child_env = build_child_env
|
|
59
|
+
rescue StandardError
|
|
60
|
+
cleanup
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def child_env = @child_env.dup
|
|
65
|
+
|
|
66
|
+
def install
|
|
67
|
+
install_path(@path)
|
|
68
|
+
%w[guardrails cost-cap].each do |name|
|
|
69
|
+
next if @manifest.name == name
|
|
70
|
+
|
|
71
|
+
install_path(File.join(SHIPPED_DIR, name))
|
|
72
|
+
end
|
|
73
|
+
@manifest
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cleanup
|
|
77
|
+
FileUtils.rm_rf(root) if root && File.exist?(root)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def write_config
|
|
83
|
+
source = ConfigFile.read_yaml(env: ENV.to_h) || {}
|
|
84
|
+
data = source.to_h { |key, value| [key.to_s, value] }
|
|
85
|
+
# Drop bundles, hooks, guardrails, memories, append_system_prompt,
|
|
86
|
+
# recap, session, log, web, update, kitty, tmux, broadcast and
|
|
87
|
+
# context sources; keep model and ordinary runtime settings.
|
|
88
|
+
CONFIG_DENYLIST.each { |key| data.delete(key) }
|
|
89
|
+
data["context"] = data["context"].dup if data["context"].is_a?(Hash)
|
|
90
|
+
data["context"]&.delete("sources")
|
|
91
|
+
data["bundles"] = { "cost-cap" => { "cap" => @cost_cap } }
|
|
92
|
+
data["bundles"][@manifest.name] ||= {}
|
|
93
|
+
data["bundles"][@manifest.name].merge!(@bundle_settings)
|
|
94
|
+
data["bundles"]["cost-cap"]["cap"] = @cost_cap if @cost_cap_override
|
|
95
|
+
path = File.join(config_home, "samagotchi", "config.yml")
|
|
96
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
97
|
+
File.write(path, YAML.dump(data))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def read_manifest
|
|
101
|
+
file = File.join(@path, "manifest.yml")
|
|
102
|
+
raise Error.new("chi bundle try: not a bundle: #{@path} (chi bundle new NAME)", status: CLI::Exit::USAGE) unless File.file?(file)
|
|
103
|
+
|
|
104
|
+
text = File.read(file)
|
|
105
|
+
stale = MemoryBundle::Shas.stale(@path, text)
|
|
106
|
+
unless stale.empty?
|
|
107
|
+
raise Error, "chi bundle try: stale or placeholder sha256 in manifest.yml; run chi bundle check --fix"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@manifest = MemoryBundle::Manifest.read(dir: @path)
|
|
111
|
+
if @manifest.meta?
|
|
112
|
+
raise Error.new("chi bundle try: profile bundles cannot be tried directly", status: CLI::Exit::USAGE)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@manifest
|
|
116
|
+
rescue MemoryBundle::Manifest::ValidationError => e
|
|
117
|
+
raise Error, "chi bundle try: #{e.message}"
|
|
118
|
+
rescue MemoryBundle::Shas::Error => e
|
|
119
|
+
raise Error, "chi bundle try: #{e.message}"
|
|
120
|
+
rescue Psych::Exception => e
|
|
121
|
+
raise Error, "chi bundle try: manifest.yml is not valid YAML (#{e.message})"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def install_path(path)
|
|
125
|
+
out, err, status = Open3.capture3(child_env, RbConfig.ruby, "-I", LIB_DIR, CHI,
|
|
126
|
+
"bundle", "install", path, "--force", chdir: work_dir)
|
|
127
|
+
return if status.success?
|
|
128
|
+
|
|
129
|
+
message = [out, err].reject(&:empty?).join("\n").strip
|
|
130
|
+
raise Error, "chi bundle try: #{message.empty? ? "bundle install failed" : message}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_child_env
|
|
134
|
+
env = ENV.to_h
|
|
135
|
+
SessionManager::SESSION_MARKER_ENV.each { |key| env.delete(key) }
|
|
136
|
+
env.delete("SAMAGOTCHI_LOG_FILE")
|
|
137
|
+
env.delete("SAMAGOTCHI_LOG_DISABLE")
|
|
138
|
+
env.delete("SAMAGOTCHI_SKIP_SYSTEM_BUNDLE")
|
|
139
|
+
env.delete("SAMAGOTCHI_ENV") if env["SAMAGOTCHI_ENV"] == "test"
|
|
140
|
+
env.delete("SAMAGOTCHI_INSTALLED_VERSION")
|
|
141
|
+
env.delete_if do |key, _|
|
|
142
|
+
key.start_with?(SessionManager::WORKER_ENV_PREFIX) || key == SessionManager::BOOT_FALLBACK_ENV ||
|
|
143
|
+
CONFIG_DENYLIST_ENV_PREFIXES.any? { |prefix| key == prefix || key.start_with?("#{prefix}_") }
|
|
144
|
+
end
|
|
145
|
+
env.merge!(
|
|
146
|
+
"HOME" => Dir.home,
|
|
147
|
+
"SAMAGOTCHI_KEYCHAIN" => "on",
|
|
148
|
+
"XDG_CONFIG_HOME" => config_home,
|
|
149
|
+
"XDG_STATE_HOME" => state_home,
|
|
150
|
+
"SAMAGOTCHI_RECAP_ENABLED" => "false",
|
|
151
|
+
"SAMAGOTCHI_CACHE_WARMUP" => "off"
|
|
152
|
+
)
|
|
153
|
+
env.merge!(InstalledGem.unbundled_env_changes(env)) if InstalledGem.spec
|
|
154
|
+
env
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|