samagotchi 0.88.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 +20 -1
- data/docs/bundle-authoring.md +19 -0
- data/docs/cli.md +41 -2
- data/docs/internals/bundles.md +10 -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/context_providers.rb +1 -2
- 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/session_manager.rb +2 -10
- data/lib/samagotchi/version.rb +1 -1
- 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,24 @@ 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
|
+
|
|
11
29
|
## [0.88.0] - 2026-10-19
|
|
12
30
|
|
|
13
31
|
### Added
|
|
@@ -3495,7 +3513,8 @@ and long-lived sessions.
|
|
|
3495
3513
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3496
3514
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3497
3515
|
|
|
3498
|
-
[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
|
|
3499
3518
|
[0.88.0]: https://github.com/dm1try/samagotchi/compare/v0.87.0...v0.88.0
|
|
3500
3519
|
[0.87.0]: https://github.com/dm1try/samagotchi/compare/v0.86.0...v0.87.0
|
|
3501
3520
|
[0.86.0]: https://github.com/dm1try/samagotchi/compare/v0.85.0...v0.86.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
|
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.
|
|
@@ -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
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../analytics_file"
|
|
5
|
+
require_relative "../../session_metrics"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
class BundleCommand
|
|
9
|
+
module Try
|
|
10
|
+
Trace = Data.define(
|
|
11
|
+
:session_id, :exit_code, :answer, :tools, :notices, :cards, :questions, :denied,
|
|
12
|
+
:interactive_blocked, :used_memories, :tokens, :cost, :cost_is_estimate, :model, :sandbox, :stderr_tail
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class TraceReader
|
|
16
|
+
DENIAL_TEXT = "No one to approve it (non-interactive run)."
|
|
17
|
+
SESSION_ID = /\A[A-Za-z0-9_-]+\z/
|
|
18
|
+
STDERR_LIMIT = 4000
|
|
19
|
+
|
|
20
|
+
def initialize(state_home:, session_id:)
|
|
21
|
+
@state_home = state_home
|
|
22
|
+
@session_id = session_id if session_id.to_s.match?(SESSION_ID)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(exit_code:, answer:, stderr:, sandbox:)
|
|
26
|
+
return empty_trace(exit_code, answer, stderr, sandbox) unless @session_id
|
|
27
|
+
|
|
28
|
+
sessions_dir = File.join(@state_home, "samagotchi", "sessions")
|
|
29
|
+
session_path = File.join(sessions_dir, "#{@session_id}.json")
|
|
30
|
+
session_dir = File.join(sessions_dir, @session_id)
|
|
31
|
+
session = json_file(session_path) || {}
|
|
32
|
+
messages = Array(session["messages"]).select { |message| message.is_a?(Hash) }
|
|
33
|
+
analytics = AnalyticsFile.read(session_dir) || {}
|
|
34
|
+
entries = card_entries(session_dir)
|
|
35
|
+
tool_rows, denied = tools(messages, analytics)
|
|
36
|
+
summary = SessionMetrics.saved_summary(session_dir)
|
|
37
|
+
cost, estimate = cost_summary(summary)
|
|
38
|
+
questions = entries.select { |entry| entry["type"] == "question" }
|
|
39
|
+
notices = entries.select { |entry| entry["type"] == "hook_notice" }
|
|
40
|
+
cards = entries.select { |entry| entry["type"] == "card" }
|
|
41
|
+
blocked_count = questions.count { |question| unanswerable?(question) } + denied.size
|
|
42
|
+
|
|
43
|
+
Trace.new(
|
|
44
|
+
session_id: @session_id, exit_code: exit_code, answer: answer.to_s, tools: tool_rows, notices: notices,
|
|
45
|
+
cards: cards, questions: questions, denied: denied, interactive_blocked: blocked_count.positive?,
|
|
46
|
+
used_memories: Array(session["used_memory_names"]), tokens: {
|
|
47
|
+
prompt: summary&.prompt_sum, completion: summary&.completion_sum
|
|
48
|
+
},
|
|
49
|
+
cost: cost, cost_is_estimate: estimate, model: session["model_name"], sandbox: sandbox,
|
|
50
|
+
stderr_tail: tail(stderr)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def empty_trace(exit_code, answer, stderr, sandbox)
|
|
57
|
+
Trace.new(session_id: nil, exit_code: exit_code, answer: answer.to_s, tools: [], notices: [], cards: [],
|
|
58
|
+
questions: [], denied: [], interactive_blocked: false, used_memories: [], tokens: {
|
|
59
|
+
prompt: nil, completion: nil
|
|
60
|
+
}, cost: nil, cost_is_estimate: false, model: nil, sandbox: sandbox, stderr_tail: tail(stderr))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tools(messages, analytics)
|
|
64
|
+
records = Array(analytics["tool_records"]).select { |record| record.is_a?(Hash) }
|
|
65
|
+
.to_h do |record|
|
|
66
|
+
[[record["iteration"].to_i, record["call_index"].to_i], record]
|
|
67
|
+
end
|
|
68
|
+
tool_responses = messages.select { |message| %w[tool_response tool].include?(message["role"]) }
|
|
69
|
+
denied = []
|
|
70
|
+
index = 0
|
|
71
|
+
iteration = 0
|
|
72
|
+
rows = messages.flat_map do |message|
|
|
73
|
+
next [] unless %w[model assistant].include?(message["role"])
|
|
74
|
+
|
|
75
|
+
iteration += 1
|
|
76
|
+
Array(message["tool_calls"]).each_with_index.filter_map do |call, call_index|
|
|
77
|
+
next unless call.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
index += 1
|
|
80
|
+
record = records[[iteration, call_index + 1]]
|
|
81
|
+
id = call["id"]
|
|
82
|
+
response = tool_responses.find { |entry| entry["tool_call_id"] == id } if id
|
|
83
|
+
response ||= tool_responses[index - 1] unless id
|
|
84
|
+
is_denied = response && response["content"].to_s.include?(DENIAL_TEXT)
|
|
85
|
+
denied << index if is_denied
|
|
86
|
+
status = is_denied ? "denied" : record&.dig("status").to_s
|
|
87
|
+
status = "unknown" if status.empty?
|
|
88
|
+
{
|
|
89
|
+
index: index,
|
|
90
|
+
name: record&.[]("called_as") || record&.[]("tool") || call_name(call),
|
|
91
|
+
args: argument_text(call),
|
|
92
|
+
status: status,
|
|
93
|
+
duration_ms: record&.[]("duration_ms").to_i
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
[rows, denied]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def call_name(call)
|
|
101
|
+
function = call["function"].is_a?(Hash) ? call["function"] : {}
|
|
102
|
+
call["name"] || function["name"] || "tool"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def argument_text(call)
|
|
106
|
+
function = call["function"].is_a?(Hash) ? call["function"] : {}
|
|
107
|
+
args = call["arguments"] || function["arguments"] || {}
|
|
108
|
+
args = JSON.parse(args) if args.is_a?(String)
|
|
109
|
+
if args.is_a?(Hash)
|
|
110
|
+
preferred = %w[command content path url query].filter_map { |key| args[key] }.first
|
|
111
|
+
args = preferred || JSON.generate(args)
|
|
112
|
+
end
|
|
113
|
+
args.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
args.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def card_entries(session_dir)
|
|
119
|
+
data = json_file(File.join(session_dir, "cards.json"))
|
|
120
|
+
entries = data.is_a?(Hash) ? data["entries"] : []
|
|
121
|
+
Array(entries).select { |entry| entry.is_a?(Hash) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def unanswerable?(question)
|
|
125
|
+
question["cancelled"] && question["reason"] == "non_interactive"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def cost_summary(summary)
|
|
129
|
+
return [nil, false] unless summary
|
|
130
|
+
|
|
131
|
+
reported = summary.cost_sum.to_f
|
|
132
|
+
estimate = summary.cost_estimate_sum.to_f
|
|
133
|
+
return [reported, false] if reported.positive?
|
|
134
|
+
return [nil, false] unless estimate.positive?
|
|
135
|
+
|
|
136
|
+
[estimate, true]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def json_file(path)
|
|
140
|
+
JSON.parse(File.read(path))
|
|
141
|
+
rescue JSON::ParserError, SystemCallError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def tail(text)
|
|
146
|
+
value = text.to_s
|
|
147
|
+
value.length > STDERR_LIMIT ? value[-STDERR_LIMIT, STDERR_LIMIT] : value
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|