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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b7fb93e6e4741b5380cfaeaa2a27d37dd326099f2d3cf568b659941e9c34930
4
- data.tar.gz: bc53710bd1f4dab4d2d5d4e1eb23814f367fc47833b930fd6bd7ee3a1411423b
3
+ metadata.gz: e5e180d3ef3bc5dd32bd646d471b1ef88e11083aba16f6908a8c4588b0173b4c
4
+ data.tar.gz: b337e5690f3094d5f3b0e8528aacbedab734211c9be60ed5ff314f6eb4299ea7
5
5
  SHA512:
6
- metadata.gz: 882ac69395d70f9b27e8b9922b704c1b1818ad400eeb4551dac38c905df7822d17ac43450f71b5c5a0653b0af9b45470098981f9ee25e36dc2f45a1bb2a9eeee
7
- data.tar.gz: 1e982daa8adf0881d87ea82256e96565cceb09ab4d6793325a87ce0214df3355a5798c3da75f9afab30126aba7bd5f7f9facf7dfd1f032e3c2cfd9a5e4aaeed4
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.83.0...HEAD
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). |
@@ -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 `child:` for a delegate child, else
1578
- `default:`. Delegates and continued links each have their own cap; there is no family cap.
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 by sending `/cap N` as its first message (`chi send --new -m '/cap 3'`).
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
- - `--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 and toolkit, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool` and `--no-tool` 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: …`.
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.0
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:bc6683af0063b90d6a3d7c2796bccf446043dfb1b3f07c04bd54d22d26199e1d
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 child: for a
168
- # delegate, else default:. Cached per session id.
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 settings.key?("default") || settings.key?("child")
196
+ return { cap: nil, step: nil } unless %w[cap default child].any? { |key| settings.key?(key) }
193
197
 
194
- key = settings.key?("child") && ctx.delegate? ? "child" : "default"
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.83.0
3
+ version: 0.84.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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
- # @return [Hash] config.yml `bundles: <bundle>:` (string keys), frozen
101
- attr_reader :settings
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 toolkit,
69
- with a note carrying its recap; it is archived.
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 and " \
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
@@ -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) do
25
- def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
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
- def set(**values)
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)] }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.83.0"
4
+ VERSION = "0.84.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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 thinking are
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 thinking; " \
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.83.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