samagotchi 0.83.0 → 0.84.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 +11 -1
- data/bin/chi +27 -2
- data/docs/cli.md +1 -0
- data/docs/internals/bundles.md +1 -1
- data/docs/plugins.md +31 -5
- data/docs/sessions.md +12 -1
- data/docs/sub-agent.md +4 -0
- data/lib/samagotchi/bundle_session_settings.rb +212 -0
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +12 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +2 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +32 -2
- data/lib/samagotchi/send_command.rb +24 -7
- data/lib/samagotchi/session.rb +16 -1
- data/lib/samagotchi/session_setup.rb +14 -6
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +7 -2
- data/lib/samagotchi/testing/bundle.rb +31 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +13 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5e180d3ef3bc5dd32bd646d471b1ef88e11083aba16f6908a8c4588b0173b4c
|
|
4
|
+
data.tar.gz: b337e5690f3094d5f3b0e8528aacbedab734211c9be60ed5ff314f6eb4299ea7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ce619eccbfc69729b160a2b19c65d3b8e91d0f2b641fb6d4eb288d8fd12e9a147529745a8a3b5272270a2fabec6e597f423086c6574cb06f689816b32550568
|
|
7
|
+
data.tar.gz: 4a9922290d92cabab3aac30f17292119cc6e778243a7e1b57bd8260423a27446b34b978346056566619f8e358da8dabbd9e80f8bc3aa9fc198dab99e9b27b38c
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,15 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.84.0] - 2026-10-18
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `--set BUNDLE.KEY=VALUE` on `chi` and `chi send --new`: a bundle setting for this session, saved with it and kept
|
|
16
|
+
by forks and continued links. A bundle lists the keys a session may set in its manifest's `session_settings:`;
|
|
17
|
+
its plugin reads them in `ctx.settings`, over config.yml's. `POST /api/sessions` takes `bundle_settings`.
|
|
18
|
+
- The cost-cap bundle takes a session's own cap: `chi send --new --set cost-cap.cap=3 -m "…"` (cost-cap 0.1.1).
|
|
19
|
+
|
|
11
20
|
## [0.83.0] - 2026-10-18
|
|
12
21
|
|
|
13
22
|
### Added
|
|
@@ -3380,7 +3389,8 @@ and long-lived sessions.
|
|
|
3380
3389
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3381
3390
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3382
3391
|
|
|
3383
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3392
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.84.0...HEAD
|
|
3393
|
+
[0.84.0]: https://github.com/dm1try/samagotchi/compare/v0.83.0...v0.84.0
|
|
3384
3394
|
[0.83.0]: https://github.com/dm1try/samagotchi/compare/v0.82.0...v0.83.0
|
|
3385
3395
|
[0.82.0]: https://github.com/dm1try/samagotchi/compare/v0.81.0...v0.82.0
|
|
3386
3396
|
[0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
|
data/bin/chi
CHANGED
|
@@ -82,7 +82,7 @@ ARGV.each do |arg|
|
|
|
82
82
|
base = arg.split("=", 2).first
|
|
83
83
|
# If dashed version is a known flag (registry or hardcoded), reject underscore variant
|
|
84
84
|
dashed = base.tr("_", "-")
|
|
85
|
-
known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
|
|
85
|
+
known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
|
|
86
86
|
if known.include?(dashed) || known.include?(base)
|
|
87
87
|
warn "Unknown option: #{arg} (did you mean #{dashed}?)"
|
|
88
88
|
exit 1
|
|
@@ -125,6 +125,11 @@ parser = OptionParser.new do |opts|
|
|
|
125
125
|
# --no-tool is taken out of ARGV before parse! (OptionParser would read it
|
|
126
126
|
# as --tool's negation); this is its help line.
|
|
127
127
|
opts.separator " --no-tool NAME Leave this tool out of the toolkit (repeatable; a comma list too)"
|
|
128
|
+
opts.on("--set BUNDLE.KEY=VALUE", "A bundle setting for this session, over config.yml's bundles: (repeatable; a key the " \
|
|
129
|
+
"bundle's manifest lists in session_settings:; numbers, true, false and off are typed, the rest is text; " \
|
|
130
|
+
"saved in the session: --set cost-cap.cap=3)") do |v|
|
|
131
|
+
(options[:sets] ||= []) << v
|
|
132
|
+
end
|
|
128
133
|
opts.on("--memory NAME", "Preload a memory entry into the system prompt (repeatable; a comma list too)") { |m| (options[:memories] ||= []) << m }
|
|
129
134
|
opts.on("--append-system-prompt FILE", "Put FILE's text into the system prompt, after AGENTS.md (repeatable; saved in the session; " \
|
|
130
135
|
"config append_system_prompt: is the baseline)") { |f| (options[:append_system_prompt] ||= []) << f }
|
|
@@ -225,6 +230,10 @@ if options[:new_token] && !web
|
|
|
225
230
|
warn "Error: --new-token goes with chi web (chi web --new-token)"
|
|
226
231
|
exit 1
|
|
227
232
|
end
|
|
233
|
+
if options[:sets] && web
|
|
234
|
+
warn "Error: --set is a new session's (chi --set, chi send --new --set), not chi web's"
|
|
235
|
+
exit 2
|
|
236
|
+
end
|
|
228
237
|
|
|
229
238
|
# A scratch session is new, runs here and goes when it ends.
|
|
230
239
|
if scratch
|
|
@@ -372,6 +381,20 @@ if options[:toolkit] || options[:tools] || options[:no_tools]
|
|
|
372
381
|
remove: options[:no_tools])
|
|
373
382
|
end
|
|
374
383
|
|
|
384
|
+
# --set BUNDLE.KEY=VALUE: a new session's bundle settings, checked here
|
|
385
|
+
# against the installed bundles (a typo isn't saved).
|
|
386
|
+
if options[:sets]
|
|
387
|
+
require "samagotchi/bundle_session_settings"
|
|
388
|
+
begin
|
|
389
|
+
options[:bundle_settings] = Samagotchi::BundleSessionSettings.from_flags(options[:sets])
|
|
390
|
+
rescue Samagotchi::BundleSessionSettings::Error => e
|
|
391
|
+
warn "Error: #{e.message}"
|
|
392
|
+
exit 2
|
|
393
|
+
end
|
|
394
|
+
# An existing session's run names it among the flags it ignores.
|
|
395
|
+
options[:toolkit_flags] = (options[:toolkit_flags] || []) + ["--set"]
|
|
396
|
+
end
|
|
397
|
+
|
|
375
398
|
chi_update_hint.call
|
|
376
399
|
require "samagotchi/launch_mode"
|
|
377
400
|
launch, launch_note = if scratch
|
|
@@ -408,7 +431,8 @@ if launch == :attached
|
|
|
408
431
|
thinking: options[:thinking],
|
|
409
432
|
appended_prompt_files: options[:appended_prompt_files] || [],
|
|
410
433
|
toolkit: options[:toolkit_value],
|
|
411
|
-
toolkit_flags: options[:toolkit_flags] || []
|
|
434
|
+
toolkit_flags: options[:toolkit_flags] || [],
|
|
435
|
+
bundle_settings: options[:bundle_settings] || {})
|
|
412
436
|
rescue Samagotchi::TerminalUI::AttachLauncher::Error => e
|
|
413
437
|
warn "Error: #{e.message}"
|
|
414
438
|
exit 1
|
|
@@ -430,6 +454,7 @@ begin
|
|
|
430
454
|
appended_prompt_files: options[:appended_prompt_files] || [],
|
|
431
455
|
toolkit: options[:toolkit_value],
|
|
432
456
|
toolkit_flags: options[:toolkit_flags] || [],
|
|
457
|
+
bundle_settings: options[:bundle_settings] || {},
|
|
433
458
|
non_interactive: options[:non_interactive],
|
|
434
459
|
model_name: options[:model],
|
|
435
460
|
scratch: scratch
|
data/docs/cli.md
CHANGED
|
@@ -204,6 +204,7 @@ controls exit behavior (`--non-interactive`); `--resume` composes with both.
|
|
|
204
204
|
| `--model NAME` | Use this model for the run (overrides the configured default and a resumed session's model). |
|
|
205
205
|
| `--thinking LEVEL` | This session's own thinking level: `off`, `low`, `medium` or `high`, saved in the session and first in the order (`default` unsets it). On `--resume` or `--attach` it is set at once, through a `/thinking` on an attached worker. With `chi web` it is instead the default for the sessions its workers run, below a session's own (as env `SAMAGOTCHI_THINKING_LEVEL` is). See "Thinking" in configuration.md. |
|
|
206
206
|
| `--toolkit NAME`, `--tool NAME`, `--no-tool NAME` | A new session's toolkit, the tools its prompts declare: `full` (every built-in), `lean`, `child`, one of config's `toolkit.kits` or a bundle's (without `--toolkit`: config's `toolkit.default`), with tools declared on top of it (`--tool`) or left out (`--no-tool`); both repeatable, a comma list too. Saved in the session as given; a respawned worker, a fork and a continue keep it. Ignored with `--attach`/`--resume` (one line, as `--memory`); `/toolkit` changes it. Not on `chi web` (config's `toolkit.default` covers its sessions). See [Toolkits](configuration.md#toolkits). |
|
|
207
|
+
| `--set BUNDLE.KEY=VALUE` | A bundle setting for a new session (repeatable; a later one of the same key wins), laid over config.yml's `bundles: <bundle>:` in what the bundle's plugin reads (`ctx.settings`): `--set cost-cap.cap=3`. The value is typed simply: an integer (`3`, `-1`) or a decimal (`2.5`) is a number, `true` is true, `false` and `off` are false, anything else stays the text as given (`010`, `0x10`, `3:30`, `No`, `null`). For cost-cap, `0`, a negative number or `off` is no cap. Only a key the installed bundle's manifest lists in `session_settings:` is taken: a bundle that isn't installed (or whose plugin doesn't load), a key it doesn't list (a record installed before the bundle listed it says to run `chi update` or `chi bundle upgrade NAME`), or another shape (`--set foo`, `--set a.b.c=1`) is an error naming what it takes, exit 2, nothing started. Saved in the session; a respawned worker, a fork and a continue keep it, a delegate child doesn't. Ignored with `--attach`/`--resume` (one line, as `--toolkit`); refused with `chi web`. See [Per-session settings](plugins.md#per-session-settings). |
|
|
207
208
|
| `--profile NAME` | Prompt profile (`qwen36` or `gemma4`) for every model in this run, over config and the server's template (same as `--model-profile`, env `SAMAGOTCHI_MODEL_PROFILE`). See "Prompt profile" in configuration.md. |
|
|
208
209
|
| `--memory NAME` | Preload a memory entry into the system prompt (repeatable; a comma list too). Merged under the config.yml `memories:` baseline. Works attached: the list is stored on the session, so its worker builds the same prompt on every respawn. |
|
|
209
210
|
| `--append-system-prompt FILE` | Put `FILE`'s text into the system prompt, after AGENTS.md and before the memory indexes (repeatable; relative to the shell's directory). Added after the config.yml `append_system_prompt:` list. A missing or unreadable file is an error, exit 1. Saved in the session, so a respawned worker, a fork and a continue keep it; ignored with `--attach`/`--resume` (one line, as `--memory`). See [Appended prompt files](configuration.md#appended-prompt-files). |
|
data/docs/internals/bundles.md
CHANGED
|
@@ -27,7 +27,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
|
|
|
27
27
|
| `SourceIdentity` (source.rb) | what a source is, for comparing two installs of one name: `shipped:<dir>` (any gem or checkout), `git:<url>` (no `#ref`, trailing `/` or `.git`), `path:<realpath>`; nil when it can't place one |
|
|
28
28
|
| `PreparedSource` (source.rb) | one source normalized once (dir, owned, git commit): `BundleCommand` reads the bundle's name from its `manifest.yml` (the name for every kind of source, a git URL too) and hands it to the Installer (`prepared:`), so a source is cloned or extracted once per command |
|
|
29
29
|
| `MemoryFiles` | which top-level `*.md` files of a bundle are its memories (`.select`: the `files:` list, else all but README/LICENSE and the like), `.unshipped` (listed, not shipped) and `.symlinks` (skipped: a symlink is never a memory) |
|
|
30
|
-
| `Manifest` | parses/validates `manifest.yml` (`name:` is the bundle's name and a directory under `.bundles/`, so a path such as `../x`, `a/b`, `.` or `..` is refused: `name: … is not a bundle name`; YAML that doesn't parse or isn't a mapping is a `ValidationError` too; files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
|
|
30
|
+
| `Manifest` | parses/validates `manifest.yml` (`name:` is the bundle's name and a directory under `.bundles/`, so a path such as `../x`, `a/b`, `.` or `..` is refused: `name: … is not a bundle name`; YAML that doesn't parse or isn't a mapping is a `ValidationError` too; files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers, session_settings: the keys a session's `chi --set` may give, recorded at install in `manifest.json` for `BundleSessionSettings` to check) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
|
|
31
31
|
| `Installer` | one install/upgrade/dry run of one bundle (below) |
|
|
32
32
|
| `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
|
|
33
33
|
| `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
|
data/docs/plugins.md
CHANGED
|
@@ -28,6 +28,7 @@ plugin:
|
|
|
28
28
|
requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
|
|
29
29
|
# requires_chi_next: true # shipped bundles only, ignored by chi: rake release:bump raises requires_chi
|
|
30
30
|
# to the chi being released (docs/releasing.md)
|
|
31
|
+
session_settings: [cap] # optional: the settings a session may set (chi --set my-bundle.cap=3)
|
|
31
32
|
needs: [gh] # optional: outside commands it relies on (see memory.md#bundles-that-need-outside-commands)
|
|
32
33
|
```
|
|
33
34
|
|
|
@@ -70,7 +71,9 @@ of [anytime](#anytime-true) and the `save_note` tool (see `chi.tool` below). Ins
|
|
|
70
71
|
|
|
71
72
|
Settings work as they do for hooks ([hooks.md](hooks.md#settings)). An
|
|
72
73
|
`initialize` that takes an argument gets the bundle's section of config.yml
|
|
73
|
-
`bundles:`, as one Hash with string keys.
|
|
74
|
+
`bundles:`, as one Hash with string keys. It runs once, when the plugin loads, before any session: it never
|
|
75
|
+
sees a session's own `--set` values. A value a session may set is read from `ctx.settings` in the handler or
|
|
76
|
+
command that uses it (see [Per-session settings](#per-session-settings)).
|
|
74
77
|
|
|
75
78
|
## The API: `register(chi)`
|
|
76
79
|
|
|
@@ -731,7 +734,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
731
734
|
| `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
|
|
732
735
|
| `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
|
|
733
736
|
| `ctx.cost` | the session's spend so far, read now, as a frozen value: `total` (USD, reported + estimated, side requests included; 0.0 before any), `reported` (by providers, `usage.cost`), `estimated` (from `hosts.<name>.models` prices; a generation is one or the other, never both), `side` (recap, `/btw`, `ask_model`: their share of `total`) and `known?` (false while generations or side requests ran but none was reported or priced, so a limit on it can't be enforced; a session with some priced generations reads known, the unpriced ones counting 0). It includes the running turn's finished generations and what earlier workers of the session spent. Use its methods, not its class. Needs chi 0.83.0 |
|
|
734
|
-
| `ctx.settings` | the bundle's settings, frozen |
|
|
737
|
+
| `ctx.settings` | the bundle's settings, frozen: config.yml's `bundles: <bundle>:` with the session's own `--set` values laid over it ([Per-session settings](#per-session-settings)); the config alone until the session is bound (in `register` and `chi.ctx`) |
|
|
735
738
|
| `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
|
|
736
739
|
| `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
|
|
737
740
|
| `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
|
|
@@ -754,6 +757,19 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
754
757
|
|
|
755
758
|
The Engine itself is never handed to a plugin.
|
|
756
759
|
|
|
760
|
+
### Per-session settings
|
|
761
|
+
|
|
762
|
+
A bundle's settings are config.yml's, for every session. A setting a session may have of its own (a child's cap,
|
|
763
|
+
say) is one the manifest lists in `session_settings:`; `chi --set BUNDLE.KEY=VALUE` and `chi send --new --set …`
|
|
764
|
+
then give it to a new session, saved with it (a respawn, a fork and a continued link keep it; a delegate child
|
|
765
|
+
doesn't). chi refuses a bundle that isn't installed (or whose plugin doesn't load) and a key the manifest doesn't
|
|
766
|
+
list, so a typo isn't saved. Values are typed simply: an integer (`3`, `-1`) or a decimal (`2.5`) is a number,
|
|
767
|
+
`true` is true, `false` and `off` are false, and anything else stays the text as given (`010`, `0x10`, `3:30`,
|
|
768
|
+
`No`, `null`); `POST /api/sessions` `bundle_settings` types its strings the same way and keeps JSON numbers and
|
|
769
|
+
booleans. `ctx.settings` is the config's section with the session's values laid over it (one level: a session's
|
|
770
|
+
`cap` replaces the config's `cap`). Read such a key from `ctx.settings` where it is used, not in
|
|
771
|
+
`initialize(settings)`: that runs once at load, before any session, and gets the config only.
|
|
772
|
+
|
|
757
773
|
## Cards
|
|
758
774
|
|
|
759
775
|
A card is a small framed message with buttons: a title, a body and
|
|
@@ -1147,6 +1163,7 @@ bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripte
|
|
|
1147
1163
|
bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe" }) # ctx.http, never the network
|
|
1148
1164
|
bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
|
|
1149
1165
|
bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
|
|
1166
|
+
bundle.set(session_settings: { "cap" => 1 }) # the session's --set values (Bundle.load takes session_settings: too)
|
|
1150
1167
|
bundle.shutdown
|
|
1151
1168
|
```
|
|
1152
1169
|
|
|
@@ -1173,6 +1190,11 @@ bundle.shutdown
|
|
|
1173
1190
|
- **`ctx.cost`** is zero and known unless the test sets it: `bundle.set(cost: 2.5)` (reported USD), a Hash of the
|
|
1174
1191
|
parts (`reported:`, `estimated:`, `side:`, `known:`; `total` is reported + estimated) or a callable giving either,
|
|
1175
1192
|
called at each read (a counting lambda shows whether the bundle read it).
|
|
1193
|
+
- **`session_settings:`** (on `Bundle.load` and `bundle.set`) are the session's own `--set` values for the bundle,
|
|
1194
|
+
keys its manifest lists in `session_settings:` (another raises `ArgumentError`, as chi refuses it). `ctx.settings`
|
|
1195
|
+
lays them over `settings:` through chi's own `Context#settings`; the plugin's `initialize(settings)` gets
|
|
1196
|
+
`settings:` only. `bundle.set(session_settings:)` moves the session to a new id (`test-session-2`, or the
|
|
1197
|
+
`session_id:` given beside it): chi never changes them within a session, and builds `ctx.settings` once per id.
|
|
1176
1198
|
- **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
|
|
1177
1199
|
go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
|
|
1178
1200
|
- **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
|
|
@@ -1574,8 +1596,10 @@ bundles:
|
|
|
1574
1596
|
Continue raises it to $6.00.` with **Continue** (another cap's worth: $3 → $6 → $9, as many steps as it takes to
|
|
1575
1597
|
pass the spend, saved for the session) and **Stop** (the turn stops, `canceled` with `stopped_by: cost-cap`, and a
|
|
1576
1598
|
card shows the spend, reported, estimated and side). With no one to ask (`--non-interactive`) it stops.
|
|
1577
|
-
- **The cap of a session:** the one `/cap` or a Continue set for it, else `
|
|
1578
|
-
`
|
|
1599
|
+
- **The cap of a session:** the one `/cap` or a Continue set for it, else its own `cap:` (`chi --set
|
|
1600
|
+
cost-cap.cap=3`, `chi send --new --set cost-cap.cap=3`; `--set cost-cap.cap=off`, `0` or a negative number for none), else `child:` for a
|
|
1601
|
+
delegate child, else `default:`. Delegates and continued links each have their own cap (a continued link keeps
|
|
1602
|
+
the `--set` cap, not a `/cap` or Continue); there is no family cap.
|
|
1579
1603
|
- **A host it can't watch:** a model that reports no cost and has no configured price (a local server, a free
|
|
1580
1604
|
model that reports 0) reads unknown: one notice, `the $3.00 cap can't be enforced: <model> reports no cost and has
|
|
1581
1605
|
no price`, and it never stops for an unknown cost. A session with some priced generations counts the unpriced
|
|
@@ -1589,7 +1613,9 @@ bundles:
|
|
|
1589
1613
|
spend is allowed: the next generation asks.
|
|
1590
1614
|
- `/cap off` (or `/cap 0`): no cap for this session, over the config's `default:`.
|
|
1591
1615
|
|
|
1592
|
-
A parent agent starts a session with a cap
|
|
1616
|
+
A parent agent starts a session with a cap of its own with `--set` (`chi send --new --set cost-cap.cap=3 -m "task"`;
|
|
1617
|
+
`cost-cap.cap` is the bundle's one session setting), or by sending `/cap N` as a first message
|
|
1618
|
+
(`chi send --new -m '/cap 3'`).
|
|
1593
1619
|
At the cap, `chi send --wait` exits 3 with a question of kind `hook` (header "Cost cap"); `chi answer ID --question
|
|
1594
1620
|
Q --option Continue` (or `--option 1`) goes on, `--option Stop` stops the turn ([sub-agent.md](sub-agent.md)). A
|
|
1595
1621
|
parent may Continue: nothing restricts it, as with the step limit. A background delegate child at its cap waits on
|
data/docs/sessions.md
CHANGED
|
@@ -209,7 +209,8 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
209
209
|
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
|
|
210
210
|
- `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
211
211
|
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|
|
212
|
-
- `--
|
|
212
|
+
- `--set BUNDLE.KEY=VALUE` (with `--new`, repeatable) gives the new session a bundle setting of its own, as `chi --set` does (see *Bundle settings* below): `chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap. A bundle or key the installed bundles don't offer is a usage error (exit 2), as is `--set` without `--new`.
|
|
213
|
+
- `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit and bundle settings, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool` and `--set` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
|
|
213
214
|
- The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
|
|
214
215
|
- A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
|
|
215
216
|
|
|
@@ -243,6 +244,16 @@ declares every tool (full). The file stores only the name: a kit's tools and pro
|
|
|
243
244
|
`/toolkit` changes it, saved at once, from the next turn's start. See
|
|
244
245
|
[Toolkits](configuration.md#toolkits).
|
|
245
246
|
|
|
247
|
+
## Bundle settings
|
|
248
|
+
|
|
249
|
+
A session file's `bundle_settings` (`{"cost-cap": {"cap": 3}}`, `{}` for none and for a session from before them)
|
|
250
|
+
holds the bundle settings it was created with: `chi --set BUNDLE.KEY=VALUE`, `chi send --new --set …`, or `POST
|
|
251
|
+
/api/sessions` `bundle_settings` (an object of the same shape, checked the same way; `400 invalid_bundle_settings`
|
|
252
|
+
otherwise). They never change after: a respawned or woken worker reads the same, and a continue (`continue →`,
|
|
253
|
+
`chi send --new --continues`) and a fork (`ctx.sessions.fork`) copy them, as they copy the toolkit. A delegate
|
|
254
|
+
child starts without (a bundle has its own setting for children, such as cost-cap's `child:`). A bundle's plugin
|
|
255
|
+
reads them in `ctx.settings`, over config.yml's `bundles:` ([Per-session settings](plugins.md#per-session-settings)).
|
|
256
|
+
|
|
246
257
|
## Delegating
|
|
247
258
|
|
|
248
259
|
The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `delegated by` in its info bar; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
|
data/docs/sub-agent.md
CHANGED
|
@@ -36,6 +36,10 @@ chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--f
|
|
|
36
36
|
chi answer ID --question QID --dismiss [--timeout S] [--format json]
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
A child with a bundle setting of its own, such as a $ cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)):
|
|
40
|
+
`chi send --new --wait --format json --set cost-cap.cap=3 -m "task"`. At the cap the wait exits 3 with the cost-cap
|
|
41
|
+
question; `chi answer ID --question QID --option Continue` (or `Stop`) answers it.
|
|
42
|
+
|
|
39
43
|
`chi answer` waits after answering, the same way `chi send --wait` does, so the
|
|
40
44
|
next question comes back as exit 3 again. What it waits for is the answered
|
|
41
45
|
turn: the turn that asked (or, for the step-limit question, the turn it
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A session's own bundle settings (chi --set BUNDLE.KEY=VALUE, chi send
|
|
8
|
+
# --new --set, POST /api/sessions bundle_settings): {bundle => {key =>
|
|
9
|
+
# value}}, string keys, scalar values (.typed). A bundle offers the keys a
|
|
10
|
+
# session may set in its manifest's session_settings: (recorded at install,
|
|
11
|
+
# InstalledBundle#session_settings); any other bundle or key is refused, so
|
|
12
|
+
# a typo isn't saved. The session keeps them (Session#bundle_settings,
|
|
13
|
+
# SessionSetup) and Plugin::Context#settings lays them over config.yml's
|
|
14
|
+
# bundles: <name>:.
|
|
15
|
+
module BundleSessionSettings
|
|
16
|
+
# A refused --set (or request field): the message says why.
|
|
17
|
+
class Error < ArgumentError; end
|
|
18
|
+
|
|
19
|
+
# A bundle name (MemoryBundle::Manifest::BUNDLE_NAME) and a setting key.
|
|
20
|
+
BUNDLE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
21
|
+
KEY = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
22
|
+
# The values a setting may take.
|
|
23
|
+
SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
|
|
24
|
+
REQUEST_SHAPE = "bundle_settings is an object of bundle => {key => scalar}"
|
|
25
|
+
# What .typed reads as a number: no leading zero, no exponent, no _.
|
|
26
|
+
INTEGER = /\A-?(?:0|[1-9]\d*)\z/
|
|
27
|
+
DECIMAL = /\A-?(?:0|[1-9]\d*)\.\d+\z/
|
|
28
|
+
# The shipped bundles' sources (a stale record's hint reads them).
|
|
29
|
+
SHIPPED_DIR = File.expand_path("bundles", __dir__)
|
|
30
|
+
|
|
31
|
+
# The installed bundles a session may set settings of: +offered+ the
|
|
32
|
+
# keys of each bundle whose plugin loads ({name => keys}), +notes+ what
|
|
33
|
+
# a refusal adds for a bundle ({name => text}): why its plugin doesn't
|
|
34
|
+
# load, or how to refresh a record from before session_settings.
|
|
35
|
+
Availability = Data.define(:offered, :notes)
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# --set's values as typed, in order: a later one of the same key wins.
|
|
40
|
+
# @param args [Array<String>] "BUNDLE.KEY=VALUE" each
|
|
41
|
+
# @return [Hash{String => Hash{String => Object}}] frozen
|
|
42
|
+
# @raise [Error] a value of another shape
|
|
43
|
+
def parse(args)
|
|
44
|
+
settings = {}
|
|
45
|
+
Array(args).each do |arg|
|
|
46
|
+
target, sep, raw = arg.to_s.partition("=")
|
|
47
|
+
bundle, *keys = target.split(".", -1)
|
|
48
|
+
if sep.empty? || keys.empty? || !BUNDLE.match?(bundle.to_s) || keys.any? { |key| !KEY.match?(key) }
|
|
49
|
+
raise Error, "--set takes BUNDLE.KEY=VALUE, not #{arg}"
|
|
50
|
+
end
|
|
51
|
+
raise Error, "--set #{arg}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
|
|
52
|
+
raise Error, "--set #{arg}: give a value" if raw.strip.empty?
|
|
53
|
+
|
|
54
|
+
(settings[bundle] ||= {})[keys.first] = typed(raw)
|
|
55
|
+
end
|
|
56
|
+
deep_freeze(settings)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# +settings+ when every bundle is installed and offers each key.
|
|
60
|
+
# @param bundles [Hash{String => Array<String>}] the installed bundles'
|
|
61
|
+
# offered keys (.offered)
|
|
62
|
+
# @param notes [Hash{String => String}] Availability#notes
|
|
63
|
+
# @param flag [String] what a refusal names ("--set", a request field)
|
|
64
|
+
# @return [Hash] +settings+
|
|
65
|
+
# @raise [Error]
|
|
66
|
+
def check!(settings, bundles: offered, notes: {}, flag: "--set")
|
|
67
|
+
settings.each do |bundle, values|
|
|
68
|
+
keys = bundles[bundle]
|
|
69
|
+
note = notes[bundle]
|
|
70
|
+
values.each_key do |key|
|
|
71
|
+
where = "#{flag} #{bundle}.#{key}"
|
|
72
|
+
raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}" if keys.nil?
|
|
73
|
+
raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
|
|
74
|
+
raise Error, "#{where}: #{bundle} takes #{keys.join(", ")}" unless keys.include?(key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
settings
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# --set's values parsed and checked (.parse, .check!) against the
|
|
81
|
+
# installed bundles (.availability).
|
|
82
|
+
def from_flags(args, available: availability)
|
|
83
|
+
check!(parse(args), bundles: available.offered, notes: available.notes)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A value as --set (and a request's string) gives it: an integer
|
|
87
|
+
# ("3", "-1"), a decimal ("2.5"), true, false or off (false); any other
|
|
88
|
+
# string stays as given ("010", "0x10", "3:30", "No", "null"). A value
|
|
89
|
+
# that isn't a String (a JSON number or boolean) is kept.
|
|
90
|
+
def typed(value)
|
|
91
|
+
return value unless value.is_a?(String)
|
|
92
|
+
|
|
93
|
+
case value
|
|
94
|
+
when INTEGER then Integer(value, 10)
|
|
95
|
+
when DECIMAL then Float(value)
|
|
96
|
+
when "true" then true
|
|
97
|
+
when "false", "off" then false
|
|
98
|
+
else value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# POST /api/sessions bundle_settings: nil for none, else an object of
|
|
103
|
+
# objects of scalars (a string typed as --set's, .typed), checked like
|
|
104
|
+
# --set; an empty bundle entry is dropped.
|
|
105
|
+
# @param bundles [Hash, nil] the offered keys (.availability's by default)
|
|
106
|
+
# @return [Hash] frozen
|
|
107
|
+
# @raise [Error]
|
|
108
|
+
def from_request(raw, bundles: nil)
|
|
109
|
+
return {} if raw.nil?
|
|
110
|
+
raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && raw.all? { |_, values| scalar_map?(values) }
|
|
111
|
+
|
|
112
|
+
settings = normalize(raw.transform_values { |values| values.transform_values { |value| typed(value) } })
|
|
113
|
+
return settings if settings.empty?
|
|
114
|
+
|
|
115
|
+
available = bundles ? Availability.new(offered: bundles, notes: {}) : availability
|
|
116
|
+
check!(settings, bundles: available.offered, notes: available.notes, flag: "bundle_settings")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Each installed bundle's offered keys, by name (Availability#offered).
|
|
120
|
+
# @return [Hash{String => Array<String>}]
|
|
121
|
+
def offered(dir: nil) = availability(dir: dir).offered
|
|
122
|
+
|
|
123
|
+
# The installed bundles a session may set settings of: a record that
|
|
124
|
+
# doesn't read is left out, and so is one whose plugin doesn't load as
|
|
125
|
+
# chi's loader decides (Plugin::Loader#unloadable_reason: requires_chi,
|
|
126
|
+
# the file, its sha256), with a note saying why. A record with no
|
|
127
|
+
# session_settings whose shipped bundle lists some (installed before
|
|
128
|
+
# them) gets a note on refreshing it.
|
|
129
|
+
# @return [Availability]
|
|
130
|
+
def availability(dir: nil, shipped_dir: SHIPPED_DIR)
|
|
131
|
+
require_relative "memory_bundle/provenance"
|
|
132
|
+
require_relative "memory_bundle/manifest"
|
|
133
|
+
dir ||= MemoryBundle::Provenance.bundles_dir
|
|
134
|
+
offered = {}
|
|
135
|
+
notes = {}
|
|
136
|
+
MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
|
|
137
|
+
next if bundle.error?
|
|
138
|
+
|
|
139
|
+
if (reason = unloadable(dir, name, bundle))
|
|
140
|
+
notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
offered[name] = bundle.session_settings
|
|
144
|
+
if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
|
|
145
|
+
notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
Availability.new(offered: offered, notes: notes)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A session file's value, read tolerantly: the bundles that map to
|
|
152
|
+
# settings, the scalar ones of those, string keys, frozen; {} for
|
|
153
|
+
# anything else.
|
|
154
|
+
# @return [Hash{String => Hash{String => Object}}]
|
|
155
|
+
def normalize(raw)
|
|
156
|
+
return {}.freeze unless raw.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
deep_freeze(raw.each_with_object({}) do |(bundle, values), acc|
|
|
159
|
+
next unless values.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
kept = values.each_with_object({}) do |(key, value), scalars|
|
|
162
|
+
scalars[key.to_s] = value if SCALARS.any? { |kind| value.is_a?(kind) }
|
|
163
|
+
end
|
|
164
|
+
acc[bundle.to_s] = kept unless kept.empty?
|
|
165
|
+
end)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Why +bundle+'s plugin doesn't load (as Plugin::Loader checks it), nil
|
|
169
|
+
# when it does or has none.
|
|
170
|
+
def unloadable(dir, name, bundle)
|
|
171
|
+
return nil unless bundle.plugin
|
|
172
|
+
|
|
173
|
+
failure = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
|
|
174
|
+
return failure if failure
|
|
175
|
+
|
|
176
|
+
file = File.join(dir, name, "plugin", bundle.plugin.file.to_s)
|
|
177
|
+
return "the file is missing" unless File.file?(file)
|
|
178
|
+
|
|
179
|
+
expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
|
|
180
|
+
return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
|
|
181
|
+
|
|
182
|
+
actual = MemoryBundle::Provenance.file_sha(file)
|
|
183
|
+
"its sha256 differs from the installed one (edited after install? reinstall the bundle)" unless actual == expected
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether chi ships a bundle +name+ whose manifest lists session_settings.
|
|
187
|
+
def shipped_settings?(shipped_dir, name)
|
|
188
|
+
path = File.join(shipped_dir, name, "manifest.yml")
|
|
189
|
+
return false unless File.file?(path)
|
|
190
|
+
|
|
191
|
+
raw = YAML.safe_load_file(path)
|
|
192
|
+
raw.is_a?(Hash) && raw["session_settings"].is_a?(Array) && !raw["session_settings"].empty?
|
|
193
|
+
rescue StandardError
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def scalar_map?(values)
|
|
198
|
+
values.is_a?(Hash) && values.values.all? { |value| SCALARS.any? { |kind| value.is_a?(kind) } }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def takers(bundles)
|
|
202
|
+
names = bundles.reject { |_, keys| keys.empty? }.keys.sort
|
|
203
|
+
names.empty? ? "no installed bundle takes --set" : "bundles that take --set: #{names.join(", ")}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def deep_freeze(settings)
|
|
207
|
+
settings.to_h { |bundle, values| [bundle.dup.freeze, values.to_h { |k, v| [k.dup.freeze, v.frozen? ? v : v.dup.freeze] }.freeze] }.freeze
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private_class_method :unloadable, :shipped_settings?, :scalar_map?, :takers, :deep_freeze
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cost-cap
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "A per-session $ cap: at the cap the turn pauses on Continue (another cap's worth) / Stop, a warning at 80% goes to the user only; /cap shows or sets it"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:2ea7f76af1ea1c1b0a9a8b0b4b11d6e85a4a816fae224ef5b220cfd20fb48f42
|
|
10
10
|
# ctx.cost is new in chi 0.83.0
|
|
11
11
|
requires_chi: ">= 0.83.0"
|
|
12
|
+
# chi --set cost-cap.cap=3: a session's own cap (ctx.settings["cap"])
|
|
13
|
+
session_settings: [cap]
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
#
|
|
11
11
|
# Settings (config.yml, bundles: cost-cap:), read at each use from
|
|
12
12
|
# ctx.settings, never at load:
|
|
13
|
+
# cap: 3.00 this session's own cap, set with chi --set cost-cap.cap=3
|
|
14
|
+
# (session_settings: in the manifest); 0/off: none
|
|
13
15
|
# default: 5.00 USD cap for a session with none set; absent/0/off: none
|
|
14
16
|
# child: 2.00 for delegate children (ctx.delegate?); absent: default
|
|
15
17
|
# warn_at: 0.8 the share of the cap that warns; 0/off: no warning
|
|
16
18
|
#
|
|
17
19
|
# State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
|
|
18
20
|
# written by /cap and by Continue; "cap": null (or false) is off for the
|
|
19
|
-
# session, over the config. Without a file the cap comes from the settings
|
|
21
|
+
# session, over the config. Without a file the cap comes from the settings:
|
|
22
|
+
# cap:, else child: for a delegate, else default:.
|
|
20
23
|
# The cap is resolved once per session id (ctx.delegate? parses the session
|
|
21
24
|
# file) and kept until /cap or Continue writes a new one.
|
|
22
25
|
require "json"
|
|
@@ -164,8 +167,9 @@ class Plugin
|
|
|
164
167
|
|
|
165
168
|
def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
|
|
166
169
|
|
|
167
|
-
# The session's cap and step now: the state file's, else
|
|
168
|
-
# delegate, else default:. Cached per
|
|
170
|
+
# The session's cap and step now: the state file's, else the session's
|
|
171
|
+
# own cap: (--set), else child: for a delegate, else default:. Cached per
|
|
172
|
+
# session id.
|
|
169
173
|
def resolve(ctx)
|
|
170
174
|
session = ctx.session_id
|
|
171
175
|
cached = @mutex.synchronize { @resolved }
|
|
@@ -189,9 +193,12 @@ class Plugin
|
|
|
189
193
|
|
|
190
194
|
def configured(ctx)
|
|
191
195
|
settings = ctx.settings
|
|
192
|
-
return { cap: nil, step: nil } unless
|
|
196
|
+
return { cap: nil, step: nil } unless %w[cap default child].any? { |key| settings.key?(key) }
|
|
193
197
|
|
|
194
|
-
key = settings.key?("
|
|
198
|
+
key = if settings.key?("cap") then "cap"
|
|
199
|
+
elsif settings.key?("child") && ctx.delegate? then "child"
|
|
200
|
+
else "default"
|
|
201
|
+
end
|
|
195
202
|
cap = self.class.amount(settings[key])
|
|
196
203
|
{ cap: cap, step: cap }
|
|
197
204
|
end
|
|
@@ -202,7 +202,7 @@ module Samagotchi
|
|
|
202
202
|
parts.scripts.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
|
|
203
203
|
end
|
|
204
204
|
|
|
205
|
-
# manifest.yml for the staged bundle: requires_chi, needs and the
|
|
205
|
+
# manifest.yml for the staged bundle: requires_chi, needs, session_settings and the
|
|
206
206
|
# file descriptions come from the installed bundle's record, the
|
|
207
207
|
# trust_level from the CLI else the record.
|
|
208
208
|
def write_manifest(staging, name, version, files_map, parts)
|
|
@@ -223,7 +223,8 @@ module Samagotchi
|
|
|
223
223
|
needs: record&.needs,
|
|
224
224
|
scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
225
225
|
context_providers: parts.context_providers,
|
|
226
|
-
file_descriptions: file_descriptions(record, files_map)
|
|
226
|
+
file_descriptions: file_descriptions(record, files_map),
|
|
227
|
+
session_settings: record&.session_settings || []
|
|
227
228
|
)
|
|
228
229
|
end
|
|
229
230
|
|
|
@@ -20,13 +20,15 @@ module Samagotchi
|
|
|
20
20
|
# the lists as recorded (nil when absent): Manifest.parse_needs
|
|
21
21
|
# and ContextProviders.parse_list check them
|
|
22
22
|
# includes a profile's recorded members, nil for a plain bundle
|
|
23
|
+
# session_settings
|
|
24
|
+
# the keys a session may set (chi --set), [] when none
|
|
23
25
|
# error why manifest.json couldn't be read (the rest empty), else nil
|
|
24
26
|
class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
|
|
25
27
|
:requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
|
|
26
|
-
:includes, :context_providers, :error)
|
|
28
|
+
:includes, :context_providers, :session_settings, :error)
|
|
27
29
|
OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
|
|
28
30
|
requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
|
|
29
|
-
includes: nil, context_providers: nil, error: nil }.freeze
|
|
31
|
+
includes: nil, context_providers: nil, session_settings: [], error: nil }.freeze
|
|
30
32
|
|
|
31
33
|
def initialize(name:, **fields)
|
|
32
34
|
super(name: name, **OPTIONAL, **fields)
|
|
@@ -57,7 +59,8 @@ module Samagotchi
|
|
|
57
59
|
guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
|
|
58
60
|
scripts: mapping(h["scripts"]) { |meta| sha(meta) },
|
|
59
61
|
plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
|
|
60
|
-
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil
|
|
62
|
+
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
|
|
63
|
+
session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [])
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def self.string(value) = value&.to_s
|
|
@@ -420,7 +420,8 @@ module Samagotchi
|
|
|
420
420
|
conflicts: @conflicts.keys,
|
|
421
421
|
scripts_files: installed.script_files,
|
|
422
422
|
context_providers: @manifest.context_providers,
|
|
423
|
-
file_descriptions: @manifest.file_descriptions
|
|
423
|
+
file_descriptions: @manifest.file_descriptions,
|
|
424
|
+
session_settings: @manifest.session_settings
|
|
424
425
|
)
|
|
425
426
|
end
|
|
426
427
|
|
|
@@ -41,6 +41,9 @@ module Samagotchi
|
|
|
41
41
|
# cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
|
|
42
42
|
# why: GitHub PR # optional
|
|
43
43
|
# every_seconds: 300 # optional
|
|
44
|
+
# session_settings: [cap] # optional — the settings a session may set
|
|
45
|
+
# # (chi --set cost-cap.cap=3), read by the
|
|
46
|
+
# # plugin from ctx.settings at call time
|
|
44
47
|
# includes: [a, b] # optional — a profile (meta bundle): the
|
|
45
48
|
# # shipped bundles it installs; its dir holds
|
|
46
49
|
# # only manifest.yml (MemoryBundle::Profile)
|
|
@@ -60,7 +63,7 @@ module Samagotchi
|
|
|
60
63
|
# the description a mapping gave, for the files that have one; hooks:
|
|
61
64
|
# name → BundleHook.
|
|
62
65
|
attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
63
|
-
:scripts, :context_providers
|
|
66
|
+
:scripts, :context_providers, :session_settings
|
|
64
67
|
|
|
65
68
|
def initialize(path:)
|
|
66
69
|
@path = path
|
|
@@ -84,6 +87,7 @@ module Samagotchi
|
|
|
84
87
|
@scripts = parse_scripts(raw["scripts"])
|
|
85
88
|
@context_providers = parse_context_providers(raw["context_providers"])
|
|
86
89
|
@includes = parse_includes(raw["includes"])
|
|
90
|
+
@session_settings = self.class.parse_session_settings(raw["session_settings"])
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
# A profile: it installs the bundles in includes instead of files.
|
|
@@ -136,7 +140,8 @@ module Samagotchi
|
|
|
136
140
|
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
137
141
|
# +file_descriptions+ (name → text) writes those files as mappings.
|
|
138
142
|
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
139
|
-
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {}
|
|
143
|
+
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
|
|
144
|
+
session_settings: [])
|
|
140
145
|
FileUtils.mkdir_p(dir)
|
|
141
146
|
manifest = {
|
|
142
147
|
"name" => name,
|
|
@@ -159,9 +164,26 @@ module Samagotchi
|
|
|
159
164
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
160
165
|
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
161
166
|
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
167
|
+
manifest["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
162
168
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
163
169
|
end
|
|
164
170
|
|
|
171
|
+
# A setting key a session may set (BundleSessionSettings::KEY).
|
|
172
|
+
SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
173
|
+
|
|
174
|
+
# session_settings: → the keys, [] when absent; anything but a list of
|
|
175
|
+
# plain key names is a ValidationError.
|
|
176
|
+
def self.parse_session_settings(raw)
|
|
177
|
+
return [] if raw.nil?
|
|
178
|
+
|
|
179
|
+
keys = raw.is_a?(Array) ? raw : nil
|
|
180
|
+
unless keys&.all? { |key| (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(SESSION_SETTING) }
|
|
181
|
+
raise ValidationError, "session_settings: a list of setting names (#{raw.inspect})"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
keys.map(&:to_s).uniq
|
|
185
|
+
end
|
|
186
|
+
|
|
165
187
|
# needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
|
|
166
188
|
# absent. Takes the manifest's string-keyed YAML or the symbol-keyed
|
|
167
189
|
# form provenance stores. Duplicates merge (the first one's why/hint
|
|
@@ -141,7 +141,7 @@ module Samagotchi
|
|
|
141
141
|
# @param context_providers [Array<ContextProviders::Provider>]
|
|
142
142
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
143
143
|
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
|
|
144
|
-
scripts_files: {}, context_providers: [], file_descriptions: {})
|
|
144
|
+
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [])
|
|
145
145
|
FileUtils.mkdir_p(@bundle_dir)
|
|
146
146
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
147
147
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -239,6 +239,7 @@ module Samagotchi
|
|
|
239
239
|
end
|
|
240
240
|
end
|
|
241
241
|
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
242
|
+
manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
242
243
|
|
|
243
244
|
write_manifest(manifest_data)
|
|
244
245
|
end
|
|
@@ -97,8 +97,24 @@ module Samagotchi
|
|
|
97
97
|
# @return [String] the bundle the plugin came with
|
|
98
98
|
attr_reader :bundle
|
|
99
99
|
|
|
100
|
-
#
|
|
101
|
-
|
|
100
|
+
# config.yml `bundles: <bundle>:` with the session's own values for
|
|
101
|
+
# the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
|
|
102
|
+
# Host's setup: SessionSetup#bundle_settings), string keys, frozen.
|
|
103
|
+
# Plugin init and chi.ctx run before the session is bound: with no
|
|
104
|
+
# session id yet it is the config alone, and it is built again once
|
|
105
|
+
# per session id (the session's values never change after it starts).
|
|
106
|
+
# A plugin's initialize(settings) gets the config only: a value a
|
|
107
|
+
# session may set is read here, at call time.
|
|
108
|
+
# @return [Hash]
|
|
109
|
+
def settings
|
|
110
|
+
id = session_id
|
|
111
|
+
cached = @session_settings
|
|
112
|
+
return cached.last if cached && cached.first == id
|
|
113
|
+
|
|
114
|
+
merged = id ? overlay(@settings, session_values) : @settings
|
|
115
|
+
@session_settings = [id, merged].freeze
|
|
116
|
+
merged
|
|
117
|
+
end
|
|
102
118
|
|
|
103
119
|
# @return [Logger]
|
|
104
120
|
attr_reader :log
|
|
@@ -419,6 +435,20 @@ module Samagotchi
|
|
|
419
435
|
nil
|
|
420
436
|
end
|
|
421
437
|
|
|
438
|
+
# The session's own values for this bundle ({} without any, or when
|
|
439
|
+
# the Host can't tell them).
|
|
440
|
+
def session_values
|
|
441
|
+
values = @host.setup&.call&.bundle_settings
|
|
442
|
+
values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
|
|
443
|
+
rescue StandardError => e
|
|
444
|
+
@log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
|
|
445
|
+
{}
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def overlay(config, values)
|
|
449
|
+
values.empty? ? config : deep_freeze(config.merge(values))
|
|
450
|
+
end
|
|
451
|
+
|
|
422
452
|
def deep_freeze(value)
|
|
423
453
|
case value
|
|
424
454
|
when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
|
|
@@ -7,6 +7,7 @@ require_relative "session"
|
|
|
7
7
|
require_relative "session_commands"
|
|
8
8
|
require_relative "session_inbox"
|
|
9
9
|
require_relative "session_manager"
|
|
10
|
+
require_relative "bundle_session_settings"
|
|
10
11
|
require_relative "session_chain"
|
|
11
12
|
require_relative "context_quote"
|
|
12
13
|
require_relative "reply_wait"
|
|
@@ -39,7 +40,7 @@ module Samagotchi
|
|
|
39
40
|
|
|
40
41
|
USAGE = <<~TEXT
|
|
41
42
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
42
|
-
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
|
|
43
|
+
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [-m TEXT] [--image PATH]...
|
|
43
44
|
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
44
45
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
46
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
@@ -65,8 +66,9 @@ module Samagotchi
|
|
|
65
66
|
(--new) start the next link of that session's chain:
|
|
66
67
|
in its folder, on its model (or --model's; one whose
|
|
67
68
|
host is gone falls back to the default, with a
|
|
68
|
-
warning), LLM context, thinking level and
|
|
69
|
-
with a note carrying its recap; it
|
|
69
|
+
warning), LLM context, thinking level, toolkit and
|
|
70
|
+
bundle settings, with a note carrying its recap; it
|
|
71
|
+
is archived.
|
|
70
72
|
last:ID names the chain's latest link.
|
|
71
73
|
Without -m the new session waits idle. Refused for a
|
|
72
74
|
session continued already (naming the next link) or
|
|
@@ -84,6 +86,12 @@ module Samagotchi
|
|
|
84
86
|
(full, lean, child, a config or bundle one; default
|
|
85
87
|
config's toolkit.default), and tools declared on top
|
|
86
88
|
of it or left out (repeatable, a comma list too)
|
|
89
|
+
--set BUNDLE.KEY=VALUE
|
|
90
|
+
(--new) a bundle setting for this session, over
|
|
91
|
+
config.yml's bundles: (repeatable; a key its bundle's
|
|
92
|
+
manifest lists in session_settings:; 3, 2.5, true,
|
|
93
|
+
false and off are typed, the rest is text:
|
|
94
|
+
--set cost-cap.cap=3)
|
|
87
95
|
--wait wait for the answer and print it (one session); the
|
|
88
96
|
other lines go to stderr. Exit 3: it waits for an
|
|
89
97
|
answer from you: the question, its options and the
|
|
@@ -141,6 +149,7 @@ module Samagotchi
|
|
|
141
149
|
f.value "--toolkit"
|
|
142
150
|
f.value "--tool", key: :tools, repeat: true
|
|
143
151
|
f.value "--no-tool", key: :no_tools, repeat: true
|
|
152
|
+
f.value "--set", key: :sets, repeat: true
|
|
144
153
|
f.value "--image", key: :images, repeat: true
|
|
145
154
|
f.switch "--wait"
|
|
146
155
|
f.switch "--cut"
|
|
@@ -284,6 +293,7 @@ module Samagotchi
|
|
|
284
293
|
%i[dir model continues thinking toolkit].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
285
294
|
TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
|
|
286
295
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
296
|
+
return usage_error("--set needs --new") unless Array(options[:sets]).empty?
|
|
287
297
|
return usage_error("give session ids") if options[:ids].empty?
|
|
288
298
|
|
|
289
299
|
options
|
|
@@ -302,10 +312,11 @@ module Samagotchi
|
|
|
302
312
|
if options[:continues]
|
|
303
313
|
taken = [("--dir" if options[:dir])] +
|
|
304
314
|
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
|
|
305
|
-
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
|
|
315
|
+
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
|
|
316
|
+
[("--set" unless Array(options[:sets]).empty?)]
|
|
306
317
|
unless taken.compact.empty?
|
|
307
|
-
return usage_error("--continues takes the previous session's folder, LLM context, thinking level
|
|
308
|
-
"toolkit; " \
|
|
318
|
+
return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
|
|
319
|
+
"toolkit and bundle settings; " \
|
|
309
320
|
"leave out #{taken.compact.join(", ")}")
|
|
310
321
|
end
|
|
311
322
|
end
|
|
@@ -327,6 +338,11 @@ module Samagotchi
|
|
|
327
338
|
end
|
|
328
339
|
toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
|
|
329
340
|
options[:toolkit_value] = toolkit if toolkit
|
|
341
|
+
begin
|
|
342
|
+
options[:bundle_settings] = BundleSessionSettings.from_flags(options[:sets]) unless Array(options[:sets]).empty?
|
|
343
|
+
rescue BundleSessionSettings::Error => e
|
|
344
|
+
return usage_error(e.message)
|
|
345
|
+
end
|
|
330
346
|
|
|
331
347
|
if options[:dir]
|
|
332
348
|
options[:dir] = File.expand_path(options[:dir])
|
|
@@ -376,7 +392,8 @@ module Samagotchi
|
|
|
376
392
|
model_name: options[:model],
|
|
377
393
|
setup: SessionSetup.new(llm_context: options[:llm_context],
|
|
378
394
|
thinking: options[:thinking],
|
|
379
|
-
toolkit: options[:toolkit_value]
|
|
395
|
+
toolkit: options[:toolkit_value],
|
|
396
|
+
bundle_settings: options[:bundle_settings] || {}),
|
|
380
397
|
state_dir: @state_dir)
|
|
381
398
|
end
|
|
382
399
|
rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -208,6 +208,18 @@ module Samagotchi
|
|
|
208
208
|
@toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# The session's own bundle settings (chi --set BUNDLE.KEY=VALUE):
|
|
212
|
+
# {bundle => {key => value}}, frozen, {} for none and for a session from
|
|
213
|
+
# before them. Given when it was created and never changed; a respawn,
|
|
214
|
+
# a continue and a fork keep them (SessionSetup.of), a delegate child
|
|
215
|
+
# doesn't. Plugin::Context#settings lays them over config.yml's.
|
|
216
|
+
attr_reader :bundle_settings
|
|
217
|
+
|
|
218
|
+
# @param value [Hash, nil] the session file's form too
|
|
219
|
+
def bundle_settings=(value)
|
|
220
|
+
@bundle_settings = BundleSessionSettings.normalize(value)
|
|
221
|
+
end
|
|
222
|
+
|
|
211
223
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
212
224
|
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
213
225
|
def stopped_by
|
|
@@ -242,7 +254,7 @@ module Samagotchi
|
|
|
242
254
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
243
255
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
244
256
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
245
|
-
toolkit: nil, interrupted_turn: nil)
|
|
257
|
+
toolkit: nil, bundle_settings: {}, interrupted_turn: nil)
|
|
246
258
|
@id = id
|
|
247
259
|
@metadata_version = metadata_version
|
|
248
260
|
@mode = mode
|
|
@@ -272,6 +284,7 @@ module Samagotchi
|
|
|
272
284
|
self.reasoning_history = reasoning_history
|
|
273
285
|
self.appended_prompt_files = appended_prompt_files
|
|
274
286
|
self.toolkit = toolkit
|
|
287
|
+
self.bundle_settings = bundle_settings
|
|
275
288
|
self.interrupted_turn = interrupted_turn
|
|
276
289
|
@turn_checkpoint = nil
|
|
277
290
|
@archived = false
|
|
@@ -369,6 +382,7 @@ module Samagotchi
|
|
|
369
382
|
reasoning_history: setup.reasoning_history,
|
|
370
383
|
appended_prompt_files: setup.appended_prompt_files,
|
|
371
384
|
toolkit: setup.toolkit,
|
|
385
|
+
bundle_settings: setup.bundle_settings,
|
|
372
386
|
continues: continues
|
|
373
387
|
)
|
|
374
388
|
end
|
|
@@ -434,6 +448,7 @@ module Samagotchi
|
|
|
434
448
|
"reasoning_history" => nil,
|
|
435
449
|
"appended_prompt_files" => [],
|
|
436
450
|
"toolkit" => nil,
|
|
451
|
+
"bundle_settings" => {},
|
|
437
452
|
"interrupted_turn" => nil
|
|
438
453
|
}.freeze
|
|
439
454
|
|
|
@@ -4,6 +4,7 @@ require_relative "llm_context_override"
|
|
|
4
4
|
require_relative "reasoning_history"
|
|
5
5
|
require_relative "thinking"
|
|
6
6
|
require_relative "toolkit"
|
|
7
|
+
require_relative "bundle_session_settings"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# A session's own settings that come before its model's, carried as one
|
|
@@ -15,20 +16,26 @@ module Samagotchi
|
|
|
15
16
|
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
16
17
|
# named (their text goes into the system prompt; [] with none),
|
|
17
18
|
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
18
|
-
# session config's toolkit.default then)
|
|
19
|
+
# session config's toolkit.default then), +bundle_settings+ the bundle
|
|
20
|
+
# settings chi --set gave ({bundle => {key => value}}, frozen; {} with
|
|
21
|
+
# none), which Plugin::Context#settings lays over config.yml's.
|
|
19
22
|
#
|
|
20
23
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
21
24
|
# and file key, and resolves it on its own. A delegate child starts
|
|
22
25
|
# without one (but the toolkit its delegate call named); a continue and a
|
|
23
26
|
# fork copy their session's (.of).
|
|
24
|
-
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit
|
|
25
|
-
|
|
27
|
+
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
|
|
28
|
+
:bundle_settings) do
|
|
29
|
+
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
30
|
+
bundle_settings: {})
|
|
26
31
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
27
|
-
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit
|
|
32
|
+
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
33
|
+
bundle_settings: BundleSessionSettings.normalize(bundle_settings))
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def empty?
|
|
31
|
-
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil?
|
|
37
|
+
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
|
|
38
|
+
bundle_settings.empty?
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
# A session's own setup, for a session that starts from it. Its
|
|
@@ -40,7 +47,8 @@ module Samagotchi
|
|
|
40
47
|
return new if session.nil?
|
|
41
48
|
|
|
42
49
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
43
|
-
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit)
|
|
50
|
+
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
|
|
51
|
+
bundle_settings: session.bundle_settings)
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
end
|
|
@@ -50,7 +50,9 @@ module Samagotchi
|
|
|
50
50
|
# session's toolkit (config's toolkit.default without); an existing
|
|
51
51
|
# session keeps its own
|
|
52
52
|
# @param toolkit_flags [Array<String>] which of those were given, for
|
|
53
|
-
# the line an existing session's run prints
|
|
53
|
+
# the line an existing session's run prints (--set too)
|
|
54
|
+
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
55
|
+
# a new session's setup carries them; an existing session keeps its own
|
|
54
56
|
# @param state_dir [String, nil] where the sessions are (the default
|
|
55
57
|
# state dir by default): the worker's, for the attached loop too
|
|
56
58
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
@@ -58,11 +60,12 @@ module Samagotchi
|
|
|
58
60
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
59
61
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
60
62
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
61
|
-
toolkit_flags: [], state_dir: nil)
|
|
63
|
+
toolkit_flags: [], bundle_settings: {}, state_dir: nil)
|
|
62
64
|
state_dir ||= Session.default_state_dir
|
|
63
65
|
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
64
66
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
65
|
-
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags
|
|
67
|
+
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
|
|
68
|
+
bundle_settings: bundle_settings)
|
|
66
69
|
commands = []
|
|
67
70
|
commands << "/model #{model}" if model && (attach || resume)
|
|
68
71
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -131,7 +134,7 @@ module Samagotchi
|
|
|
131
134
|
# @raise [Error]
|
|
132
135
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
133
136
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
134
|
-
toolkit_flags: [], err: $stderr)
|
|
137
|
+
toolkit_flags: [], bundle_settings: {}, err: $stderr)
|
|
135
138
|
sd = state_dir || Session.default_state_dir
|
|
136
139
|
if attach || resume
|
|
137
140
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
|
|
@@ -153,7 +156,7 @@ module Samagotchi
|
|
|
153
156
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
154
157
|
thinking: Thinking.session_level(thinking),
|
|
155
158
|
appended_prompt_files: appended_prompt_files,
|
|
156
|
-
toolkit: toolkit))
|
|
159
|
+
toolkit: toolkit, bundle_settings: bundle_settings))
|
|
157
160
|
end
|
|
158
161
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
159
162
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -100,12 +100,16 @@ module Samagotchi
|
|
|
100
100
|
# @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
|
|
101
101
|
# session's toolkit (config's toolkit.default without); a resumed one
|
|
102
102
|
# keeps its own, with a line naming +toolkit_flags+ (the flags given)
|
|
103
|
+
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
104
|
+
# a new session's own; a resumed one keeps its own (--set is in
|
|
105
|
+
# +toolkit_flags+ then)
|
|
103
106
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
104
107
|
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
105
|
-
appended_prompt_files: [], toolkit: nil, toolkit_flags: [])
|
|
108
|
+
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {})
|
|
106
109
|
@scratch = scratch
|
|
107
110
|
@toolkit = toolkit
|
|
108
111
|
@toolkit_flags = toolkit_flags
|
|
112
|
+
@bundle_settings = bundle_settings
|
|
109
113
|
@thinking_word = thinking
|
|
110
114
|
@prompt = prompt
|
|
111
115
|
@llm_context_words = llm_context
|
|
@@ -274,7 +278,8 @@ module Samagotchi
|
|
|
274
278
|
preloaded_memory_names: @requested_memories,
|
|
275
279
|
muted_memory_names: @muted_memory_names,
|
|
276
280
|
scratch: @scratch,
|
|
277
|
-
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured
|
|
281
|
+
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
|
|
282
|
+
bundle_settings: @bundle_settings)
|
|
278
283
|
))
|
|
279
284
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
280
285
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -11,6 +11,7 @@ require_relative "../peer_sources"
|
|
|
11
11
|
require_relative "../toolkit"
|
|
12
12
|
require_relative "../guardrails/gate"
|
|
13
13
|
require_relative "../memory_bundle/manifest"
|
|
14
|
+
require_relative "../session_setup"
|
|
14
15
|
require_relative "sandbox"
|
|
15
16
|
require_relative "recorder"
|
|
16
17
|
require_relative "host"
|
|
@@ -74,11 +75,15 @@ module Samagotchi
|
|
|
74
75
|
# @param dir [String] the bundle's folder (its manifest.yml)
|
|
75
76
|
# @param settings [Hash] config.yml `bundles: <name>:`
|
|
76
77
|
# @param host [Hash] session values (Recorder::DEFAULTS' keys)
|
|
78
|
+
# @param session_settings [Hash] the session's own values for the
|
|
79
|
+
# bundle (chi --set), keys its manifest lists in session_settings:;
|
|
80
|
+
# ctx.settings lays them over +settings+ (Plugin::Context#settings),
|
|
81
|
+
# initialize(settings) doesn't see them
|
|
77
82
|
# @param sandbox [Sandbox, nil] default Sandbox.current, else one of
|
|
78
83
|
# its own, left at #shutdown
|
|
79
84
|
# @return [Bundle]
|
|
80
|
-
def load(dir, settings: {}, host: {}, sandbox: nil)
|
|
81
|
-
new(dir, settings: settings, host: host, sandbox: sandbox).tap(&:load!)
|
|
85
|
+
def load(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
|
|
86
|
+
new(dir, settings: settings, host: host, session_settings: session_settings, sandbox: sandbox).tap(&:load!)
|
|
82
87
|
end
|
|
83
88
|
|
|
84
89
|
# A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
|
|
@@ -118,14 +123,16 @@ module Samagotchi
|
|
|
118
123
|
# @return [Integer] how often chi was told the tools changed
|
|
119
124
|
attr_reader :tools_changes
|
|
120
125
|
|
|
121
|
-
def initialize(dir, settings: {}, host: {}, sandbox: nil)
|
|
126
|
+
def initialize(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
|
|
122
127
|
@dir = File.expand_path(dir)
|
|
123
128
|
@manifest = MemoryBundle::Manifest.read(dir: @dir)
|
|
124
129
|
@name = @manifest.name
|
|
125
130
|
@settings = stringify(settings || {})
|
|
131
|
+
@session_moves = 1
|
|
132
|
+
own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
|
|
126
133
|
@own_sandbox = sandbox.nil? && Sandbox.current.nil?
|
|
127
134
|
@sandbox = sandbox || Sandbox.current || Sandbox.enter
|
|
128
|
-
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host)
|
|
135
|
+
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host, **own)
|
|
129
136
|
@sessions = Sessions.new
|
|
130
137
|
@http = FakeHttp.new
|
|
131
138
|
@inits = []
|
|
@@ -309,7 +316,16 @@ module Samagotchi
|
|
|
309
316
|
end
|
|
310
317
|
|
|
311
318
|
# Change the session's values (cwd:, messages:, running: …).
|
|
312
|
-
|
|
319
|
+
# +session_settings+ changes the session's own values for the bundle
|
|
320
|
+
# (as Bundle.load's) and moves it to a new session id
|
|
321
|
+
# ("test-session-2", or the session_id: given beside it): chi never
|
|
322
|
+
# changes them within a session, and ctx.settings is built once per
|
|
323
|
+
# session id.
|
|
324
|
+
def set(session_settings: nil, **values)
|
|
325
|
+
unless session_settings.nil?
|
|
326
|
+
values[:setup] = setup_with(session_settings, @recorder[:setup])
|
|
327
|
+
values[:session_id] ||= "#{@recorder[:session_id] || "test-session"}-#{@session_moves += 1}"
|
|
328
|
+
end
|
|
313
329
|
@recorder.set(**values)
|
|
314
330
|
self
|
|
315
331
|
end
|
|
@@ -363,6 +379,16 @@ module Samagotchi
|
|
|
363
379
|
result
|
|
364
380
|
end
|
|
365
381
|
|
|
382
|
+
# +setup+ (the recorder's, nil for none) with +session_settings+ as
|
|
383
|
+
# this bundle's own values, checked against the manifest's
|
|
384
|
+
# session_settings: as chi --set checks them.
|
|
385
|
+
# @raise [ArgumentError] a key the manifest doesn't list
|
|
386
|
+
def setup_with(session_settings, setup)
|
|
387
|
+
values = { @name => stringify(session_settings) }
|
|
388
|
+
BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings }, flag: "session_settings")
|
|
389
|
+
(setup || SessionSetup.new).with(bundle_settings: values)
|
|
390
|
+
end
|
|
391
|
+
|
|
366
392
|
def stringify(value)
|
|
367
393
|
case value
|
|
368
394
|
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -649,6 +649,15 @@ module Samagotchi
|
|
|
649
649
|
thinking, error = request_thinking(body["thinking"])
|
|
650
650
|
return error if error
|
|
651
651
|
|
|
652
|
+
# bundle_settings: the session's own bundle settings (chi --set:
|
|
653
|
+
# {bundle => {key => scalar}}), checked against the installed
|
|
654
|
+
# bundles the same way.
|
|
655
|
+
begin
|
|
656
|
+
bundle_settings = BundleSessionSettings.from_request(body["bundle_settings"])
|
|
657
|
+
rescue BundleSessionSettings::Error => e
|
|
658
|
+
return error_response(400, "invalid_bundle_settings", e.message)
|
|
659
|
+
end
|
|
660
|
+
|
|
652
661
|
folder = dir ? { working_directory: dir } : {}
|
|
653
662
|
# model: the model the session starts on (the start page's picker,
|
|
654
663
|
# spelled as GET /api/models lists it); blank means the default.
|
|
@@ -659,7 +668,7 @@ module Samagotchi
|
|
|
659
668
|
# until that turn is saved.
|
|
660
669
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
661
670
|
folder[:title] = preview unless preview.empty?
|
|
662
|
-
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking)
|
|
671
|
+
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, bundle_settings: bundle_settings)
|
|
663
672
|
folder[:setup] = setup unless setup.empty?
|
|
664
673
|
begin
|
|
665
674
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
@@ -679,7 +688,7 @@ module Samagotchi
|
|
|
679
688
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
680
689
|
# link of that session's chain (SessionManager.continue_session), in its
|
|
681
690
|
# folder and with its own setup (SessionSetup: llm_context, thinking
|
|
682
|
-
# and reasoning_history levels), so dir, llm_context and
|
|
691
|
+
# and reasoning_history levels, bundle settings), so dir, llm_context, thinking and bundle_settings are
|
|
683
692
|
# refused beside it (a create takes no reasoning_history). It runs on
|
|
684
693
|
# model (or model_name) when given, else on the previous link's: one
|
|
685
694
|
# whose host is gone falls back to the default, and the 201's
|
|
@@ -694,9 +703,9 @@ module Samagotchi
|
|
|
694
703
|
unless given.is_a?(String) && !given.strip.empty?
|
|
695
704
|
return error_response(400, "invalid_continues", "continues is a session id")
|
|
696
705
|
end
|
|
697
|
-
if (fields = %w[dir llm_context thinking].select { |key| body.key?(key) }).any?
|
|
706
|
+
if (fields = %w[dir llm_context thinking bundle_settings].select { |key| body.key?(key) }).any?
|
|
698
707
|
return error_response(400, "invalid_continues",
|
|
699
|
-
"continues takes the previous session's folder, llm_context and
|
|
708
|
+
"continues takes the previous session's folder, llm_context, thinking and bundle_settings; " \
|
|
700
709
|
"leave out #{fields.join(", ")}")
|
|
701
710
|
end
|
|
702
711
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.84.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- lib/samagotchi/broadcast_command.rb
|
|
181
181
|
- lib/samagotchi/bundle_command.rb
|
|
182
182
|
- lib/samagotchi/bundle_needs.rb
|
|
183
|
+
- lib/samagotchi/bundle_session_settings.rb
|
|
183
184
|
- lib/samagotchi/bundles/agents-md/manifest.yml
|
|
184
185
|
- lib/samagotchi/bundles/agents-md/plugin.rb
|
|
185
186
|
- lib/samagotchi/bundles/btw/manifest.yml
|