llm-experiment 0.1.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 +7 -0
- data/CHANGELOG.md +40 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +190 -0
- data/exe/llmx +6 -0
- data/lib/llm-experiment.rb +3 -0
- data/lib/llm_experiment/auth.rb +143 -0
- data/lib/llm_experiment/cleaner.rb +183 -0
- data/lib/llm_experiment/cli/build_command.rb +37 -0
- data/lib/llm_experiment/cli/clean_command.rb +42 -0
- data/lib/llm_experiment/cli/doctor_command.rb +30 -0
- data/lib/llm_experiment/cli/login_command.rb +25 -0
- data/lib/llm_experiment/cli/metrics_command.rb +32 -0
- data/lib/llm_experiment/cli/new_command.rb +21 -0
- data/lib/llm_experiment/cli/parse_command.rb +77 -0
- data/lib/llm_experiment/cli/run_command.rb +74 -0
- data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
- data/lib/llm_experiment/cli/shell_command.rb +35 -0
- data/lib/llm_experiment/cli/status_command.rb +69 -0
- data/lib/llm_experiment/cli/version_command.rb +19 -0
- data/lib/llm_experiment/cli.rb +120 -0
- data/lib/llm_experiment/container.rb +178 -0
- data/lib/llm_experiment/doctor.rb +126 -0
- data/lib/llm_experiment/experiment.rb +163 -0
- data/lib/llm_experiment/grid.rb +117 -0
- data/lib/llm_experiment/image_builder/app.rb +267 -0
- data/lib/llm_experiment/image_builder/base.rb +64 -0
- data/lib/llm_experiment/metrics_report.rb +138 -0
- data/lib/llm_experiment/pins.rb +22 -0
- data/lib/llm_experiment/sanitizer.rb +144 -0
- data/lib/llm_experiment/scaffold.rb +43 -0
- data/lib/llm_experiment/shell.rb +60 -0
- data/lib/llm_experiment/stats.rb +69 -0
- data/lib/llm_experiment/transcript/claude.rb +104 -0
- data/lib/llm_experiment/transcript/codex.rb +96 -0
- data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
- data/lib/llm_experiment/transcript/parser.rb +105 -0
- data/lib/llm_experiment/transcript.rb +27 -0
- data/lib/llm_experiment/trial.rb +204 -0
- data/lib/llm_experiment/version.rb +5 -0
- data/lib/llm_experiment.rb +70 -0
- data/templates/README.md.erb +26 -0
- data/templates/base.Containerfile +120 -0
- data/templates/experiment.yml.erb +30 -0
- data/templates/gitignore +3 -0
- data/templates/runner.rb +305 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6bb9441528decd9413786b8a2da04d71bafe0b515183d26fbf90e0c36d5e7cb5
|
|
4
|
+
data.tar.gz: 7bf9c1e2ad2be746c967bf6ebd010c034d9707379f4d1a2fc2a9ae33e462e416
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 85312ed37cbbe34223c90ced1b91805fdf7e80bdec0e252ac1a7962dd4a46b1ef3efd18ac8bfe01796d8bde3943bdf4afd7c95309a137d84388a6650ae262c43
|
|
7
|
+
data.tar.gz: '039e17b7b29ec5728c8f2c0b6f07ab7de956cbfbc06b0aed6a4a9380e50f113b017d6c28b228d9b8402598393ac69d24a164e93127367bfd7d055b220fafb798'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-08-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `llmx new` — scaffold an experiment directory.
|
|
13
|
+
- `llmx doctor` — preflight the `container` CLI, apiserver, builder envelope, disk, auth store, images, and prompts.
|
|
14
|
+
- `llmx build base` — build and smoke-check the base image from pinned toolchains.
|
|
15
|
+
- `llmx build app KEY` — build a per-app image from a git bundle, with history flattened.
|
|
16
|
+
- `llmx login` — interactive plan-subscription login into the host auth store.
|
|
17
|
+
- `llmx run` — one trial, or the whole grid with `--all`.
|
|
18
|
+
- `llmx status` — grid completion per task, agent, and condition.
|
|
19
|
+
- `llmx shell [APP]` — interactive shell in a trial-shaped container.
|
|
20
|
+
- `llmx parse` — transcripts to `events.jsonl` and `metrics.json`, with hermeticity warnings.
|
|
21
|
+
- `llmx metrics` — medians and exact Mann-Whitney U, never pooled across agent or app.
|
|
22
|
+
- `llmx sanitize` — the gate from `results-raw/` to the committed `results/`.
|
|
23
|
+
- `llmx clean` — disk reclamation verified by observation, with the kill-the-VM recovery documented in its output.
|
|
24
|
+
- `llmx version` — gem version and default toolchain pins.
|
|
25
|
+
|
|
26
|
+
### Notable properties
|
|
27
|
+
|
|
28
|
+
- Zero runtime dependencies. Stdlib only.
|
|
29
|
+
- Every `container` state change is verified by observation, never by exit code.
|
|
30
|
+
- Image builds refuse to start without room: 10 GB for the base, `LLMX_MIN_FREE_GB` (25 by default) for an app. Measured against a real Rails build that consumed about 24 GB, most of it the builder VM.
|
|
31
|
+
- The builder is provisioned with 6 CPUs and 8 GB, because a starved builder dies mid-build with no error. An existing builder is reused only when it meets both dimensions; CPUs alone accepted a builder with 6 CPUs and 2 GB.
|
|
32
|
+
- Credentials never enter an image. Each trial copies them out of the mount and repoints the CLI at the copy.
|
|
33
|
+
- Prompts cross into a container base64-encoded, never through shell interpolation.
|
|
34
|
+
- App images carry only orphan `trial/*` branches; the build fails if any other commit message survives.
|
|
35
|
+
- A trial aborts (exit 3) unless the suite ran and failed before the agent started.
|
|
36
|
+
- Trials run sequentially, blocked by task, condition order rotated, resumable from `meta.json`.
|
|
37
|
+
- Every trial records requested model, reported model, CLI versions, and gem version.
|
|
38
|
+
- `llmx sanitize` refuses rather than redacts, and publishes raw transcripts only for apps marked `publish_transcripts: true`.
|
|
39
|
+
|
|
40
|
+
[0.1.0]: https://github.com/lucianghinda/llm-experiment/releases/tag/v0.1.0
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
In the interest of fostering an open and welcoming environment, we as
|
|
6
|
+
contributors and maintainers pledge to making participation in our project and
|
|
7
|
+
our community a harassment-free experience for everyone, regardless of age, body
|
|
8
|
+
size, disability, ethnicity, gender identity and expression, level of experience,
|
|
9
|
+
nationality, personal appearance, race, religion, or sexual identity and
|
|
10
|
+
orientation.
|
|
11
|
+
|
|
12
|
+
## Our Standards
|
|
13
|
+
|
|
14
|
+
Examples of behavior that contributes to creating a positive environment
|
|
15
|
+
include:
|
|
16
|
+
|
|
17
|
+
* Using welcoming and inclusive language
|
|
18
|
+
* Being respectful of differing viewpoints and experiences
|
|
19
|
+
* Gracefully accepting constructive criticism
|
|
20
|
+
* Focusing on what is best for the community
|
|
21
|
+
* Showing empathy towards other community members
|
|
22
|
+
|
|
23
|
+
Examples of unacceptable behavior by participants include:
|
|
24
|
+
|
|
25
|
+
* The use of sexualized language or imagery and unwelcome sexual attention or
|
|
26
|
+
advances
|
|
27
|
+
* Trolling, insulting/derogatory comments, and personal or political attacks
|
|
28
|
+
* Public or private harassment
|
|
29
|
+
* Publishing others' private information, such as a physical or electronic
|
|
30
|
+
address, without explicit permission
|
|
31
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
32
|
+
professional setting
|
|
33
|
+
|
|
34
|
+
## Our Responsibilities
|
|
35
|
+
|
|
36
|
+
Project maintainers are responsible for clarifying the standards of acceptable
|
|
37
|
+
behavior and are expected to take appropriate and fair corrective action in
|
|
38
|
+
response to any instances of unacceptable behavior.
|
|
39
|
+
|
|
40
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
|
41
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
|
42
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
|
43
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
|
44
|
+
threatening, offensive, or harmful.
|
|
45
|
+
|
|
46
|
+
## Scope
|
|
47
|
+
|
|
48
|
+
This Code of Conduct applies both within project spaces and in public spaces
|
|
49
|
+
when an individual is representing the project or its community. Examples of
|
|
50
|
+
representing a project or community include using an official project e-mail
|
|
51
|
+
address, posting via an official social media account, or acting as an appointed
|
|
52
|
+
representative at an online or offline event. Representation of a project may be
|
|
53
|
+
further defined and clarified by project maintainers.
|
|
54
|
+
|
|
55
|
+
## Enforcement
|
|
56
|
+
|
|
57
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
58
|
+
reported by contacting the project team at dev@ghinda.com. All
|
|
59
|
+
complaints will be reviewed and investigated and will result in a response that
|
|
60
|
+
is deemed necessary and appropriate to the circumstances. The project team is
|
|
61
|
+
obligated to maintain confidentiality with regard to the reporter of an incident.
|
|
62
|
+
Further details of specific enforcement policies may be posted separately.
|
|
63
|
+
|
|
64
|
+
Project maintainers who do not follow or enforce the Code of Conduct in good
|
|
65
|
+
faith may face temporary or permanent repercussions as determined by other
|
|
66
|
+
members of the project's leadership.
|
|
67
|
+
|
|
68
|
+
## Attribution
|
|
69
|
+
|
|
70
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
|
71
|
+
available at [http://contributor-covenant.org/version/1/4][version]
|
|
72
|
+
|
|
73
|
+
[homepage]: http://contributor-covenant.org
|
|
74
|
+
[version]: http://contributor-covenant.org/version/1/4/
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucian Ghinda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# llm-experiment
|
|
2
|
+
|
|
3
|
+
Run controlled, reproducible experiments on coding agents
|
|
4
|
+
|
|
5
|
+
Each trial runs in a fresh container with no host state. The host only orchestrates.
|
|
6
|
+
|
|
7
|
+
Supports Claude Code and Codex CLI.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
`llmx` runs repeatable coding-agent experiments inside disposable containers. Each trial starts from the same code and task. It records the agent's work, reruns the tests, and collects comparable results.
|
|
12
|
+
|
|
13
|
+
1. Define tasks, agents, and conditions in `experiment.yml`.
|
|
14
|
+
2. Build pinned container images for reproducibility.
|
|
15
|
+
3. Generate the task × agent × condition trial matrix.
|
|
16
|
+
4. Run every trial in a clean, isolated container.
|
|
17
|
+
5. Capture transcripts, diffs, test results, and metrics.
|
|
18
|
+
6. Compare conditions and export sanitized, publishable results.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- macOS on Apple silicon, using Apple's container runtime
|
|
23
|
+
- The `container` CLI installed and available on your `PATH` (verified with 0.5.0)
|
|
24
|
+
- Ruby 3.4+
|
|
25
|
+
|
|
26
|
+
The gem has no runtime dependencies.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Run:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
gem install llm-experiment
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This installs the `llmx` command.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
llmx new my-question
|
|
42
|
+
cd my-question
|
|
43
|
+
$EDITOR experiment.yml
|
|
44
|
+
export LLMX_APP_CAMPFIRE=/path/to/checkout
|
|
45
|
+
llmx doctor
|
|
46
|
+
llmx build base
|
|
47
|
+
llmx login
|
|
48
|
+
llmx build app campfire
|
|
49
|
+
llmx run --all --dry-run
|
|
50
|
+
llmx run --all
|
|
51
|
+
llmx parse --all
|
|
52
|
+
llmx metrics
|
|
53
|
+
llmx sanitize
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Write your prompts to `prompts/<app>/<task-id>/<condition>.txt` before running.
|
|
57
|
+
|
|
58
|
+
The gem runs trials. It does not write your prompts or plant your tasks.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
Every command takes `--help`, `--experiment DIR`, and `--verbose`. Use `--verbose` to echo each container command; it is off by default because a grid runs hundreds of them.
|
|
63
|
+
|
|
64
|
+
Command | Description
|
|
65
|
+
--- | ---
|
|
66
|
+
`llmx new NAME` | Scaffold an experiment directory
|
|
67
|
+
`llmx doctor` | Check CLI, services, disk, auth, images, and prompts
|
|
68
|
+
`llmx build base` | Build and smoke-check the base image
|
|
69
|
+
`llmx build app KEY` | Build a per-app image from a git bundle
|
|
70
|
+
`llmx login` | Log the agent CLIs in to your plan subscriptions
|
|
71
|
+
`llmx run` | Run one trial, or the whole grid with `--all`
|
|
72
|
+
`llmx status` | Show which grid cells have results
|
|
73
|
+
`llmx shell [APP]` | Open a shell in a trial-shaped container
|
|
74
|
+
`llmx parse` | Turn transcripts into `events.jsonl` and `metrics.json`
|
|
75
|
+
`llmx metrics` | Report medians and exact Mann-Whitney U
|
|
76
|
+
`llmx sanitize` | Gate `results-raw/` into the committed `results/`
|
|
77
|
+
`llmx clean` | Reclaim disk, verified by observation
|
|
78
|
+
`llmx version` | Show the gem version and the default pins
|
|
79
|
+
|
|
80
|
+
Destructive commands take `--dry-run`. The grid resumes by default, so pass `--redo` to re-run a finished cell.
|
|
81
|
+
|
|
82
|
+
## experiment.yml
|
|
83
|
+
|
|
84
|
+
One file configures the experiment.
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
name: at-file-mentions
|
|
88
|
+
question: "Does @path change what an agent does and what it costs?"
|
|
89
|
+
|
|
90
|
+
# Optional. Defaults ship with the gem; `llmx version` prints them.
|
|
91
|
+
pins:
|
|
92
|
+
claude_code: "2.1.233"
|
|
93
|
+
codex: "0.147.0"
|
|
94
|
+
opencode: "1.18.15"
|
|
95
|
+
ruby_versions: ["3.4.5", "4.0.1"]
|
|
96
|
+
node: "22"
|
|
97
|
+
|
|
98
|
+
# Trial agents. Only claude and codex are supported.
|
|
99
|
+
agents:
|
|
100
|
+
claude:
|
|
101
|
+
model: claude-opus-5
|
|
102
|
+
codex:
|
|
103
|
+
model: gpt-5.6-sol
|
|
104
|
+
|
|
105
|
+
# One prompt variant per condition.
|
|
106
|
+
conditions: [none, bare_noline, at_noline]
|
|
107
|
+
|
|
108
|
+
trial:
|
|
109
|
+
timeout_seconds: 900
|
|
110
|
+
memory: 6g # a unit suffix is required
|
|
111
|
+
cpus: 4
|
|
112
|
+
|
|
113
|
+
apps:
|
|
114
|
+
campfire:
|
|
115
|
+
ruby: "3.4.5"
|
|
116
|
+
bundler: "4.0.13" # or "default"
|
|
117
|
+
database: sqlite3 # sqlite3 or postgresql
|
|
118
|
+
test_command: bin/rails test
|
|
119
|
+
db_prepare: bin/rails db:test:prepare
|
|
120
|
+
suite_ran_pattern: '\d+ runs?, \d+ assertions?' # default shown
|
|
121
|
+
publish_transcripts: true # false for private code
|
|
122
|
+
branch_prefix: exp/path-hints # source branches on the host
|
|
123
|
+
neutralize: [CLAUDE.md, AGENTS.md, .mcp.json, .claude]
|
|
124
|
+
|
|
125
|
+
tasks:
|
|
126
|
+
- id: campfire-01
|
|
127
|
+
app: campfire
|
|
128
|
+
test_file: test/lib/restricted_http/private_network_guard_test.rb
|
|
129
|
+
impl_files: [lib/restricted_http/private_network_guard.rb]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Unknown keys are errors. A typo that silently does nothing is how an experiment measures the wrong thing.
|
|
133
|
+
|
|
134
|
+
`postgresql` is available in the config format, but that full in-container path has not yet been validated end to end against a real app. Prefer `sqlite3` unless you plan to verify the PostgreSQL path yourself.
|
|
135
|
+
|
|
136
|
+
Host checkout paths never go in this file. They come from `LLMX_APP_<KEY>`.
|
|
137
|
+
|
|
138
|
+
Set one per app:
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
export LLMX_APP_CAMPFIRE=/path/to/campfire
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The image builder expects two branch names per app: `<branch_prefix>/base` and `<branch_prefix>/<task-id>`.
|
|
145
|
+
|
|
146
|
+
## How a trial stays clean
|
|
147
|
+
|
|
148
|
+
A trial measures the agent, not the machine it ran on.
|
|
149
|
+
|
|
150
|
+
- Every trial gets a fresh container. Nothing survives it.
|
|
151
|
+
- No host state crosses: no user `CLAUDE.md`, no MCP servers, no hooks, no project memory.
|
|
152
|
+
- Credentials live in `~/.llmx/auth` and are mounted, never baked into an image.
|
|
153
|
+
- The mount is a seed, not a home. Each trial copies the credential files and repoints `CLAUDE_CONFIG_DIR` or `CODEX_HOME` at the copy.
|
|
154
|
+
- Codex also runs with `--ignore-user-config --ephemeral`.
|
|
155
|
+
- Application code enters as a git bundle, so untracked secrets and local bundler overrides stay behind.
|
|
156
|
+
- History flattens to one anonymous orphan commit per branch, named "Import application source". The build fails if any other message survives.
|
|
157
|
+
- Coaching files listed under `neutralize` are deleted during the image build.
|
|
158
|
+
- Prompts cross as base64 in `LLMX_PROMPT_B64`, never as shell text.
|
|
159
|
+
- A trial aborts with exit 3 unless the suite ran and failed before the agent started.
|
|
160
|
+
- `llmx parse` exits non-zero on any trial that reports an MCP server, or a memory path inside the shared mount. It checks where a path points, not whether one exists. Pass `--lenient` to report and continue.
|
|
161
|
+
|
|
162
|
+
## Disk
|
|
163
|
+
|
|
164
|
+
Budget about 10 GB for the base image and 4-8 GB per app image. The BuildKit cache reached 9.8 GB building one Rails app.
|
|
165
|
+
|
|
166
|
+
Plan against the unpacked snapshot, not the sizes `container image inspect` reports. Deleting three app images returned about 16 GB against manifests summing to 1.2-1.6 GB each.
|
|
167
|
+
|
|
168
|
+
Run `llmx clean` for a report that measures the snapshot:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
llmx clean # report only, deletes nothing
|
|
172
|
+
llmx clean --images # every llmx-app-* image, about 5 GB each
|
|
173
|
+
llmx clean --builder # the BuildKit cache, which is regenerable
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Both `llmx build base` and `llmx build app` refuse to start without room: 10 GB for the base, `LLMX_MIN_FREE_GB` (25 by default) for an app. Running out mid-build does not fail cleanly. The 25 is measured, not guessed — a Rails app image took about 24 GB, roughly 17 GB of it the BuildKit builder VM rather than the image.
|
|
177
|
+
|
|
178
|
+
`llmx clean --builder` verifies with `container ls -a` afterwards. `container builder stop` can exit 0 and do nothing, so the exit code is never the evidence.
|
|
179
|
+
|
|
180
|
+
## Private code
|
|
181
|
+
|
|
182
|
+
Images built from private repositories are never pushed to a registry.
|
|
183
|
+
|
|
184
|
+
`llmx sanitize` is the gate between `results-raw/` and the committed `results/`. It refuses the whole publish on a host path or anything shaped like a secret. It refuses rather than redacts, because a surprise in a transcript should stop the commit.
|
|
185
|
+
|
|
186
|
+
Set `publish_transcripts: false` for a private app. Only derived measurements cross; raw transcripts and diffs stay in `results-raw/`, which is gitignored.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
data/exe/llmx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
# One-time interactive step: log the agent CLIs in to the user's plan
|
|
5
|
+
# subscriptions, and keep the credentials in a directory that every later
|
|
6
|
+
# trial mounts read-write.
|
|
7
|
+
#
|
|
8
|
+
# Nothing is baked into an image and no API key is required. Re-run this when
|
|
9
|
+
# a refresh token expires; `llmx run` stops with that instruction rather than
|
|
10
|
+
# burning trials against a dead login.
|
|
11
|
+
#
|
|
12
|
+
# Claude and Codex are pointed at the credential store through
|
|
13
|
+
# CLAUDE_CONFIG_DIR and CODEX_HOME, so their logins land there directly.
|
|
14
|
+
# opencode takes no such variable: it keeps credentials in XDG_DATA_HOME next
|
|
15
|
+
# to its sessions and its database, so logging in writes a whole state
|
|
16
|
+
# directory. This points XDG_DATA_HOME at a container-local directory that
|
|
17
|
+
# dies with the container and copies out the one credential file afterwards,
|
|
18
|
+
# which gets opencode the same shape as the other two without carrying its
|
|
19
|
+
# session store along.
|
|
20
|
+
class Auth
|
|
21
|
+
AGENTS = %w[claude codex opencode].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(container: nil, shell: Shell)
|
|
24
|
+
@container = container || Container.new
|
|
25
|
+
@shell = shell
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def login(agents: AGENTS)
|
|
29
|
+
wanted = wanted_agents(agents)
|
|
30
|
+
image = ready_image!
|
|
31
|
+
|
|
32
|
+
print_intro(wanted)
|
|
33
|
+
# Interactive: hand the terminal to the container so the device flows work.
|
|
34
|
+
@shell.interactive(*run_argv(image, login_script, interactive: true))
|
|
35
|
+
|
|
36
|
+
puts "\nVerifying what persisted..."
|
|
37
|
+
@shell.sh(*run_argv(image, verify_script), allow_failure: true)
|
|
38
|
+
print_persisted
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def verify
|
|
42
|
+
@shell.sh(*run_argv(ready_image!, verify_script))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def wanted_agents(agents)
|
|
48
|
+
wanted = Array(agents).map(&:to_s)
|
|
49
|
+
wanted = AGENTS.dup if wanted.empty?
|
|
50
|
+
unknown = wanted - AGENTS
|
|
51
|
+
raise Error, "unknown agent: #{unknown.join(", ")} (supported: #{AGENTS.join(", ")})" if unknown.any?
|
|
52
|
+
|
|
53
|
+
wanted
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ready_image!
|
|
57
|
+
@container.ensure_system!
|
|
58
|
+
image = LLMExperiment.base_image
|
|
59
|
+
raise Error, "base image #{image} not found; run `llmx build base` first" unless @container.image?(image)
|
|
60
|
+
|
|
61
|
+
image
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run_argv(image, script, interactive: false)
|
|
65
|
+
@container.run_argv(image, script,
|
|
66
|
+
memory: LLMExperiment.default_memory,
|
|
67
|
+
cpus: LLMExperiment.default_cpus,
|
|
68
|
+
interactive: interactive)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# opencode's credentials live in XDG_DATA_HOME with its sessions and
|
|
72
|
+
# database, so the login runs against a directory inside the container and
|
|
73
|
+
# only auth.json is copied back out to the mounted store.
|
|
74
|
+
def opencode_login_home = "/home/#{LLMExperiment.agent_user}/.opencode-login"
|
|
75
|
+
def opencode_auth_out = "/home/#{LLMExperiment.agent_user}/.agent-auth/opencode/auth.json"
|
|
76
|
+
|
|
77
|
+
def verify_script
|
|
78
|
+
<<~SH
|
|
79
|
+
echo "--- claude ---"
|
|
80
|
+
claude auth status 2>&1 | head -5 || echo "claude: no status subcommand on this build"
|
|
81
|
+
echo "--- codex ---"
|
|
82
|
+
codex login status 2>&1 | head -5 || echo "codex: not logged in"
|
|
83
|
+
echo "--- opencode ---"
|
|
84
|
+
if [ -s "#{opencode_auth_out}" ]; then
|
|
85
|
+
echo "opencode: auth.json present ($(wc -c < "#{opencode_auth_out}") bytes)"
|
|
86
|
+
else
|
|
87
|
+
echo "opencode: no credential file in the store"
|
|
88
|
+
fi
|
|
89
|
+
SH
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `exec bash` would replace this shell and the copy-out would never run, so
|
|
93
|
+
# the shell is called and the file is moved after it returns.
|
|
94
|
+
def login_script
|
|
95
|
+
<<~SH
|
|
96
|
+
export XDG_DATA_HOME=#{opencode_login_home}
|
|
97
|
+
mkdir -p "$XDG_DATA_HOME"
|
|
98
|
+
echo "CLAUDE_CONFIG_DIR=$CLAUDE_CONFIG_DIR"
|
|
99
|
+
echo "CODEX_HOME=$CODEX_HOME"
|
|
100
|
+
echo "XDG_DATA_HOME=$XDG_DATA_HOME (opencode; dies with this container)"
|
|
101
|
+
echo
|
|
102
|
+
bash
|
|
103
|
+
if [ -s "$XDG_DATA_HOME/opencode/auth.json" ]; then
|
|
104
|
+
mkdir -p "$(dirname #{opencode_auth_out})"
|
|
105
|
+
cp "$XDG_DATA_HOME/opencode/auth.json" "#{opencode_auth_out}"
|
|
106
|
+
chmod 600 "#{opencode_auth_out}"
|
|
107
|
+
echo "copied opencode auth.json into the credential store"
|
|
108
|
+
fi
|
|
109
|
+
SH
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def print_intro(wanted)
|
|
113
|
+
puts <<~INTRO
|
|
114
|
+
|
|
115
|
+
Logging the agent CLIs in to your plan subscriptions.
|
|
116
|
+
|
|
117
|
+
A container will open with a shell. Run the login command(s) shown below and
|
|
118
|
+
complete the browser device flow, then type `exit`.
|
|
119
|
+
|
|
120
|
+
Credentials are written to #{LLMExperiment.auth_dir} on this Mac and mounted into every
|
|
121
|
+
trial. They never enter an image and are never committed.
|
|
122
|
+
|
|
123
|
+
INTRO
|
|
124
|
+
|
|
125
|
+
steps = []
|
|
126
|
+
if wanted.include?("claude")
|
|
127
|
+
steps << "claude -> run: claude (then /login, complete the browser flow, then /exit)"
|
|
128
|
+
end
|
|
129
|
+
steps << "codex -> run: codex login" if wanted.include?("codex")
|
|
130
|
+
steps << "opencode -> run: opencode auth login" if wanted.include?("opencode")
|
|
131
|
+
puts steps.map { |s| " #{s}" }.join("\n")
|
|
132
|
+
puts "\n When you are done, type: exit\n\n"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def print_persisted
|
|
136
|
+
counts = AGENTS.map do |name|
|
|
137
|
+
"#{name}/ has #{Dir.glob(File.join(LLMExperiment.auth_dir, name, "**", "*")).size} file(s)"
|
|
138
|
+
end
|
|
139
|
+
puts "\n#{LLMExperiment.auth_dir}: #{counts.join(", ")}"
|
|
140
|
+
puts "If one is empty, that login did not persist - re-run `llmx login --agent NAME` for it."
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
# Disk reclamation, planned and verified by observation.
|
|
5
|
+
#
|
|
6
|
+
# Two measured facts shape this class. First, what an image costs is its
|
|
7
|
+
# unpacked snapshot, not the layer sizes `container image inspect` reports:
|
|
8
|
+
# deleting three app images returned about 16 GB against the 1.2-1.6 GB the
|
|
9
|
+
# manifests sum to. So the plan comes from `du` on the data directory.
|
|
10
|
+
#
|
|
11
|
+
# Second, `container builder stop` can report success and do nothing. On
|
|
12
|
+
# 0.5.0, with a builder up for three days, `builder stop`, `delete --force
|
|
13
|
+
# buildkit` and `system stop` all exited 0 while `container ls -a` went on
|
|
14
|
+
# reporting the builder as running and its 6.8 GB directory stayed put. So
|
|
15
|
+
# the builder is checked afterwards, and an exit code is never the evidence.
|
|
16
|
+
class Cleaner
|
|
17
|
+
UNITS = { "B" => 1, "K" => 1024, "M" => 1024**2, "G" => 1024**3, "T" => 1024**4 }.freeze
|
|
18
|
+
|
|
19
|
+
APP_PREFIX = "llmx-app-"
|
|
20
|
+
BASE_PREFIX = "llmx-base"
|
|
21
|
+
|
|
22
|
+
# Roughly what one app image costs unpacked, measured on 0.5.0.
|
|
23
|
+
APP_IMAGE_GB = 5
|
|
24
|
+
|
|
25
|
+
SNAPSHOT_NOTE = <<~NOTE
|
|
26
|
+
Sizes above are the unpacked snapshot, which is what an image actually
|
|
27
|
+
costs. `container image inspect` sums layers instead and reports about a
|
|
28
|
+
third of it: three app images that summed to 1.2-1.6 GB each returned
|
|
29
|
+
about 16 GB when deleted. Plan against this listing, not the manifest.
|
|
30
|
+
NOTE
|
|
31
|
+
|
|
32
|
+
# Printed instead of run. The runtime line is unambiguous because it names
|
|
33
|
+
# `--uuid buildkit`, but the VM line is a bare Virtualization.framework XPC
|
|
34
|
+
# service that looks identical for any VM on the Mac, so a pattern-kill on
|
|
35
|
+
# the name can take out Docker, UTM, Parallels or a Simulator.
|
|
36
|
+
RECOVERY = <<~TEXT
|
|
37
|
+
The builder is still running. Its exit codes lied; end the VM directly:
|
|
38
|
+
|
|
39
|
+
ps -eo pid,etime,command | grep -E 'container-runtime-linux|Virtualization' | grep -v grep
|
|
40
|
+
kill -TERM <runtime-pid> <vm-pid>
|
|
41
|
+
container delete --force buildkit
|
|
42
|
+
|
|
43
|
+
Read that `ps` output before killing anything. The runtime line names
|
|
44
|
+
`--uuid buildkit`, so it is unambiguous. The VM line is a bare
|
|
45
|
+
`Virtualization.framework` XPC service and looks identical for ANY VM on
|
|
46
|
+
this Mac (Docker, UTM, Parallels, a Simulator). Match it to the runtime
|
|
47
|
+
process; never pattern-kill on the name.
|
|
48
|
+
|
|
49
|
+
A plain TERM was enough when this happened; -9 was not needed. The
|
|
50
|
+
apiserver does not need to be touched. The builder is recreated on the
|
|
51
|
+
next build, which re-downloads the builder image once.
|
|
52
|
+
TEXT
|
|
53
|
+
|
|
54
|
+
def initialize(container:, shell: Shell)
|
|
55
|
+
@container = container
|
|
56
|
+
@shell = shell
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Never deletes anything. Shows what is on disk and what it would cost to
|
|
60
|
+
# get back.
|
|
61
|
+
def report
|
|
62
|
+
puts "container data: #{data_dir}"
|
|
63
|
+
print_disk
|
|
64
|
+
puts
|
|
65
|
+
print_images
|
|
66
|
+
puts
|
|
67
|
+
puts SNAPSHOT_NOTE
|
|
68
|
+
puts " llmx clean --builder the BuildKit cache; regenerable, often the largest item"
|
|
69
|
+
puts " llmx clean --images every #{APP_PREFIX}* image, about #{APP_IMAGE_GB} GB each unpacked"
|
|
70
|
+
puts " add --dry-run to either to see the list without deleting"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# App images only. The base image takes 20-40 minutes to rebuild, so it
|
|
74
|
+
# goes only when asked for by name.
|
|
75
|
+
def clean_images(dry_run:, base: false)
|
|
76
|
+
targets = app_images
|
|
77
|
+
targets += base_images if base
|
|
78
|
+
if targets.empty?
|
|
79
|
+
puts "no llmx images to delete"
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if dry_run
|
|
84
|
+
targets.each { |image| puts "would delete #{image} (about #{APP_IMAGE_GB} GB unpacked)" }
|
|
85
|
+
puts "nothing deleted (--dry-run)"
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
before = @container.free_gb
|
|
90
|
+
targets.each do |image|
|
|
91
|
+
puts "deleting #{image}"
|
|
92
|
+
@container.delete_image(image)
|
|
93
|
+
end
|
|
94
|
+
after = @container.free_gb
|
|
95
|
+
puts "free space: #{before} GB -> #{after} GB"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Stops the builder, then checks whether it actually stopped. The exit
|
|
99
|
+
# codes of the two commands are ignored on purpose.
|
|
100
|
+
def clean_builder(dry_run:)
|
|
101
|
+
if dry_run
|
|
102
|
+
puts "would run: container builder stop"
|
|
103
|
+
puts "would run: container delete --force buildkit"
|
|
104
|
+
puts "then verify with `container ls -a`, because those two can exit 0 and do nothing"
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@shell.sh("container", "builder", "stop", allow_failure: true)
|
|
109
|
+
@shell.sh("container", "delete", "--force", "buildkit", allow_failure: true)
|
|
110
|
+
verify_builder_gone!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def data_dir = LLMExperiment.container_data_dir
|
|
116
|
+
|
|
117
|
+
def verify_builder_gone!
|
|
118
|
+
if builder_running?
|
|
119
|
+
puts
|
|
120
|
+
puts RECOVERY
|
|
121
|
+
raise Error, "builder still reported as running by `container ls -a` after stop and delete"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
puts "builder gone (verified with `container ls -a`)"
|
|
125
|
+
puts "images are untouched; the builder is recreated on the next build"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def builder_running?
|
|
129
|
+
@container.containers_all.lines.any? { |line| line.match?(/^buildkit\b.*\brunning\b/) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def app_images
|
|
133
|
+
@container.images.select { |image| image.start_with?(APP_PREFIX) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def base_images
|
|
137
|
+
@container.images.select { |image| image.start_with?(BASE_PREFIX) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def print_images
|
|
141
|
+
apps = app_images
|
|
142
|
+
puts "app images (#{apps.size}):"
|
|
143
|
+
if apps.empty?
|
|
144
|
+
puts " none"
|
|
145
|
+
else
|
|
146
|
+
apps.each { |image| puts " #{image}" }
|
|
147
|
+
end
|
|
148
|
+
base = base_images
|
|
149
|
+
puts "base images (kept unless you pass --base):"
|
|
150
|
+
puts(base.empty? ? " none" : base.map { |image| " #{image}" })
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def print_disk
|
|
154
|
+
entries = disk_entries
|
|
155
|
+
if entries.empty?
|
|
156
|
+
puts " nothing on disk, or the CLI keeps its data somewhere else"
|
|
157
|
+
puts " (set LLMX_CONTAINER_DATA_DIR if this path is wrong)"
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
entries.each { |size, path| puts format(" %-8s %s", size, File.basename(path)) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `du -sh` on each entry, largest first. Human sizes are what a person
|
|
165
|
+
# compares against a disk, so they are parsed only to sort them.
|
|
166
|
+
def disk_entries
|
|
167
|
+
return [] unless Dir.exist?(data_dir)
|
|
168
|
+
|
|
169
|
+
paths = Dir.children(data_dir).sort.map { |child| File.join(data_dir, child) }
|
|
170
|
+
return [] if paths.empty?
|
|
171
|
+
|
|
172
|
+
rows = @shell.capture("du", "-sh", *paths, allow_failure: true).lines.filter_map do |line|
|
|
173
|
+
size, path = line.chomp.split("\t", 2)
|
|
174
|
+
[size, path] if path
|
|
175
|
+
end
|
|
176
|
+
rows.sort_by { |size, _path| -bytes(size) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def bytes(size)
|
|
180
|
+
size.to_f * UNITS.fetch(size[-1].to_s.upcase, 1)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|