samagotchi 0.82.0 → 0.83.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: 02be0edba759be799428a67ac174beba8a77c2869f3de8f677dec9a14977ae60
4
- data.tar.gz: d28e0688aec5428264fc7a48162a7f4e03ea461371cc48aba4da3c64ecf2e9d8
3
+ metadata.gz: 9b7fb93e6e4741b5380cfaeaa2a27d37dd326099f2d3cf568b659941e9c34930
4
+ data.tar.gz: bc53710bd1f4dab4d2d5d4e1eb23814f367fc47833b930fd6bd7ee3a1411423b
5
5
  SHA512:
6
- metadata.gz: 1df47ccd973923c62848fef02807f3982ede1b86ebf032425c1ecd311014103286803822a290b24f117d705e3e8f845b22cb4804e90cc731a8dd48a3946a3b6f
7
- data.tar.gz: 3c901380929266d60c820ebba9ad3581bdb4392f498fbb43dcef6ba157e675c100a8dba752ee27a79d464692ffb0a407b75ead89961f38c89e6551bf8a9a6ada
6
+ metadata.gz: 882ac69395d70f9b27e8b9922b704c1b1818ad400eeb4551dac38c905df7822d17ac43450f71b5c5a0653b0af9b45470098981f9ee25e36dc2f45a1bb2a9eeee
7
+ data.tar.gz: 1e982daa8adf0881d87ea82256e96565cceb09ab4d6793325a87ce0214df3355a5798c3da75f9afab30126aba7bd5f7f9facf7dfd1f032e3c2cfd9a5e4aaeed4
data/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.83.0] - 2026-10-18
12
+
13
+ ### Added
14
+
15
+ - Bundles: `ctx.cost` reads the session's spend so far (reported + estimated, side requests included).
16
+ - The cost-cap bundle (in the core profile): a per-session $ cap that pauses the turn with Continue / Stop, `/cap`
17
+ to see or set it; config `bundles: cost-cap: default/child/warn_at`. Existing installs get it with `chi update`
18
+ (core 0.3.2, + cost-cap).
19
+ - Docs: docs/configuration.md "When a change applies" says, per config.yml key, whether an edit applies on the
20
+ next turn, at worker start, at session creation or at `chi web` start.
21
+
22
+ ### Fixed
23
+
24
+ - A delegate reply already taken as a report isn't returned again by `delegate_result` in the same turn.
25
+ - `chi send --wait ID` with no message recognizes a finished turn's reply across time zones.
26
+
11
27
  ## [0.82.0] - 2026-10-18
12
28
 
13
29
  ### Added
@@ -3364,7 +3380,8 @@ and long-lived sessions.
3364
3380
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3365
3381
  hotkey panel that send selected text or the clipboard to your sessions.
3366
3382
 
3367
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.82.0...HEAD
3383
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.83.0...HEAD
3384
+ [0.83.0]: https://github.com/dm1try/samagotchi/compare/v0.82.0...v0.83.0
3368
3385
  [0.82.0]: https://github.com/dm1try/samagotchi/compare/v0.81.0...v0.82.0
3369
3386
  [0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
3370
3387
  [0.80.0]: https://github.com/dm1try/samagotchi/compare/v0.79.0...v0.80.0
data/README.md CHANGED
@@ -78,8 +78,8 @@ Messages API, picks the
78
78
  model (or asks, when there are several), sends one test request and writes
79
79
  the config. With a config already there, it adds a `hosts:` entry and keeps
80
80
  the rest of the file. Then it installs the `core` bundles (loop-guard,
81
- check-in, guardrails, slow-commands, agents-md: the safety set for a small
82
- local model) and, on a terminal, offers `dev` (known-names, mcp, btw, skills,
81
+ check-in, guardrails, slow-commands, agents-md, cost-cap: the safety set for a
82
+ small local model, the cost cap inert until you set one) and, on a terminal, offers `dev` (known-names, mcp, btw, skills,
83
83
  source-links). Edit the
84
84
  file later as in [Configure](#configure); `chi bootstrap --help` has the
85
85
  options.
data/docs/architecture.md CHANGED
@@ -346,6 +346,9 @@ Side requests (the idle recap, a plugin's `ctx.ask_model` such as `/btw`) are co
346
346
  `analytics.json` between turns (a running turn's end saves it). `tokens`, `model_segments` and the turn records
347
347
  stay the turns' only. `SessionMetrics#persist` is serialized (its own mutex, taken before the metrics one), so two
348
348
  threads' saves land in snapshot order.
349
+ A bundle's `ctx.cost` reads the same sums (`SessionMetrics#cost`, a frozen `SessionMetrics::Cost`): the `tokens`
350
+ block's `cost_sum` and `cost_estimate_sum` with the running turn's finished generations folded in, plus the `side`
351
+ block's, so it counts what earlier workers spent too ([plugins.md](plugins.md)).
349
352
 
350
353
  The page's frequent reads of `GET /api/sessions/:id` stay light too:
351
354
  - `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
@@ -100,15 +100,15 @@ Behavior:
100
100
  `config: unknown key 'default.modle' (did you mean 'default.model'?)`.
101
101
  Names you choose under the maps below (host names, model ids) don't warn.
102
102
  - Environment variables and CLI flags win over config-file values.
103
- - An edit to the file needs no restart of `chi web`: a new session's worker
104
- reads the file when it starts, and a running chi takes a changed value the
105
- next time it reads that setting (a worker keeps `hosts:` and what it set up
106
- at its start until it is stopped; the web's model picker lists a `hosts:`
107
- edit at once). A worker gets the CLI flags of the chi that started it
108
- through its environment.
109
- - A worker reads `hosts:` from the file as it starts, as every other setting, so a delegate (or any session
110
- started later) sees a host added after its parent started. Hosts come only from config.yml: no environment
111
- variable sets them.
103
+ - An edit to the file needs no restart of `chi web`. A new worker reads the
104
+ file when it starts; a running worker sees only settings whose readers check
105
+ the file again (see [When a change applies](#when-a-change-applies)). The web
106
+ model picker refreshes `hosts:` when the file changes, but a running worker
107
+ keeps its host registry until it is restarted. A worker gets the CLI flags of
108
+ the chi that started it through its environment.
109
+ - A newly started worker reads `hosts:` from the file, so a delegate (or any
110
+ session started later) sees a host added after its parent started. Hosts
111
+ come only from config.yml: no environment variable sets them.
112
112
 
113
113
  This lets you run `chi` without repeating common defaults such as model
114
114
  and llama host/port on every invocation.
@@ -125,6 +125,49 @@ any per-run `--memory` values (config baseline first, deduped). A per-run
125
125
  `--mute NAME` removes an entry from the merged list for that session (see
126
126
  "Muting a memory" in cli.md).
127
127
 
128
+ ## When a change applies
129
+
130
+ The config reader checks the file's modification time and size whenever a
131
+ setting is read. That makes a changed value available to a live reader; it
132
+ does not reload every object that already holds a snapshot. "Worker start"
133
+ includes a worker respawn. A running turn keeps the settings it already
134
+ resolved.
135
+
136
+ | Key | Applies | Note |
137
+ |---|---|---|
138
+ | `default.model` | New session | Existing sessions keep their saved model; change one with `/model`. |
139
+ | `default.input`, `no_default_input`, `no_interrupt` | Next CLI run | These set up that run's first prompt or turn limit. |
140
+ | `default.max_tokens`, `max_tool_output_chars`, `read.*`, `execute.*` except `execute.description`, `image.*` | Next matching request or tool use | An operation already in progress keeps its resolved limits. |
141
+ | `server.host`, `server.port`, `server.transport`, `hosts.*` | Worker start | Includes host addresses, APIs, declared models and host-level settings. The web model picker refreshes `hosts:` on file change; a running worker does not. |
142
+ | `server.open_timeout`, `server.read_timeout`, `server.first_token_timeout` | Worker start | The worker's clients retain the timeouts set when they were built. |
143
+ | `model_aliases` | Next model lookup | A session keeps its resolved model unless it is explicitly switched. |
144
+ | `models.<key>.profile` | Session start or `/model` | It is resolved when that model is selected; editing it does not switch the current model's profile by itself. |
145
+ | Other `models.<key>.*` settings | Next turn or model lookup | Sampling, thinking, vision, context, window, price and steer settings are resolved from the file for their next use. |
146
+ | `thinking.level` | Next turn start | A session's own `/thinking` level is saved in that session and takes precedence; a running turn keeps its level. |
147
+ | `cache.*` | Next request or turn-end warm-up | Cache key and TTL are read for a request; warm-up is checked after a turn. |
148
+ | `retry.max`, `retry.base_delay`, `retry.max_delay` | Worker start | The client's network retry policy is built with its HTTP client. |
149
+ | `retry.empty_answer` | Next empty-answer retry | It is checked when a turn needs to retry an empty answer. |
150
+ | `memories` | Worker start | The running engine holds its preload list. The config list is not saved in the session file, so a respawn reads the current list. |
151
+ | `append_system_prompt` | Worker start for the file list | The config list is not saved in the session file. File text is read when the prompt is built; `/model` rebuilds it, while an already-built prompt is cached. CLI `--append-system-prompt` files are saved in the session. |
152
+ | `toolkit.default`, `toolkit.child` | Session creation | The chosen toolkit is saved; changing the defaults does not move existing sessions. |
153
+ | `toolkit.kits`, `execute.description`, `llm_context.policy` | Worker start | These are held by the engine's toolkit or tool registry. |
154
+ | `thinking.turn_preamble`, `skip_agents_md` | Prompt rebuild | A running engine caches its built prompt; a new worker or `/model` rebuild uses the changed value. |
155
+ | `context.*` except `context.every_seconds`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `status.line` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. |
156
+ | `context.every_seconds` | Next context poll | The poller checks it for each source that has no explicit interval. |
157
+ | `guardrails.*` | Next tool call or approval | Guardrail rules and disable lists are reloaded when the config file changes; rule settings take effect on that evaluation. |
158
+ | `hooks`, `bundles` | Worker start | Hooks, plugins and their bundle settings load with the engine. |
159
+ | `recap.*` | Worker start | The engine builds its recap job once. |
160
+ | `session.shared` | Next `chi` invocation | It selects attached-worker or in-process mode for that invocation. |
161
+ | `session.idle_exit_minutes`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer; the other values are read for the next corresponding decision. |
162
+ | `session.retention_*` | Next retention sweep | `chi web` sweeps on its configured schedule and on `GET /api/sessions`; `chi sessions prune` uses the values for that invocation. |
163
+ | `web.*` | `chi web` start | Restart `chi web` to change its bind address, port or page settings. |
164
+ | `log.*` | Process start | Each process holds its configured log sink and level after first use; restart that process. |
165
+ | `history.file` | Next history access | The TUI and web composer resolve its path when they read or append history. |
166
+ | `broadcast.*` | Next `chi broadcast` | Each broadcast invocation reads its targeting and triage settings. |
167
+ | `update.*` | Next `chi update` | The command reads which components to update when it runs. |
168
+ | `kitty.*`, `tmux.*` | `chi desktop upgrade` or desktop update | These settings are copied into the desktop helper's launch file. |
169
+ | `model.profile`, `reasoning_history` | Not from config.yml | These are CLI/environment settings; `models:` and `hosts:` provide their model- and host-specific forms. |
170
+
128
171
  ### Environment variables
129
172
 
130
173
  Every setting has an environment variable (except the ones the table marks
data/docs/hooks.md CHANGED
@@ -592,7 +592,7 @@ Notes:
592
592
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
593
593
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
594
594
  - A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md). There `ctx.notify`, `ctx.steer` and the other `ctx` helpers act as this page's `event[:notify]`… do for the event the block runs for.
595
- - Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Plugins](plugins.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Plugins](plugins.md#the-agents-md-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
595
+ - Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Plugins](plugins.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Plugins](plugins.md#the-agents-md-bundle)), `chi bundle install cost-cap` (a plugin: a per-session $ cap, `/cap`, see [Plugins](plugins.md#the-cost-cap-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
596
596
  - Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
597
597
 
598
598
  Lifecycle:
data/docs/memory.md CHANGED
@@ -316,7 +316,7 @@ profiles, meta bundles that install a set of them:
316
316
 
317
317
  | profile | bundles |
318
318
  |---|---|
319
- | `core` | loop-guard, check-in, guardrails, slow-commands, agents-md: the recommended safety set |
319
+ | `core` | loop-guard, check-in, guardrails, slow-commands, agents-md, cost-cap: the recommended safety set |
320
320
  | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer, codex-peer |
321
321
 
322
322
  `chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
data/docs/plugins.md CHANGED
@@ -730,6 +730,7 @@ full label, `plugin.rb (bundle my-bundle)`.
730
730
  | `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
731
731
  | `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
732
732
  | `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
733
+ | `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 |
733
734
  | `ctx.settings` | the bundle's settings, frozen |
734
735
  | `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 |
735
736
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
@@ -1081,7 +1082,7 @@ CHANGELOG line and bumps the shipped bundles that use it. Anything else under
1081
1082
 
1082
1083
  **Stable**
1083
1084
 
1084
- 1. `Samagotchi::Plugin::Api` / `chi.*` as documented above, including
1085
+ 1. `Samagotchi::Plugin::Api` / `chi.*` and `ctx` as documented above (`ctx.cost`'s value by its methods), including
1085
1086
  `Samagotchi::Plugin::ToolResult`, `Samagotchi::Plugin::ModelError` (and its
1086
1087
  subclass `Samagotchi::Plugin::ModelCancelled`),
1087
1088
  `Samagotchi::Plugin::Service::Stopped`, `Samagotchi::Plugin::Sessions::Error`
@@ -1145,6 +1146,7 @@ bundle.model_answers << "a summary" # the next ask_model's
1145
1146
  bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripted and recorded
1146
1147
  bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe" }) # ctx.http, never the network
1147
1148
  bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
1149
+ bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
1148
1150
  bundle.shutdown
1149
1151
  ```
1150
1152
 
@@ -1168,6 +1170,9 @@ bundle.shutdown
1168
1170
  **fails the test at teardown** (RSpec and Minitest adapters: after the cleanup, and only a test that passed), so a
1169
1171
  spec can't reach the network. With plain `Bundle.load` there is no teardown: check `bundle.http.unmatched`.
1170
1172
  `bundle.set(cancelled: true)` makes a call without `cancel:` raise `HttpCancelled`.
1173
+ - **`ctx.cost`** is zero and known unless the test sets it: `bundle.set(cost: 2.5)` (reported USD), a Hash of the
1174
+ parts (`reported:`, `estimated:`, `side:`, `known:`; `total` is reported + estimated) or a callable giving either,
1175
+ called at each read (a counting lambda shows whether the bundle read it).
1171
1176
  - **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
1172
1177
  go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
1173
1178
  - **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
@@ -1542,6 +1547,57 @@ Not caught (yet):
1542
1547
  - loops across turns;
1543
1548
  - alternating calls (A, B, A, B) that each return something new.
1544
1549
 
1550
+ ## The cost-cap bundle
1551
+
1552
+ `chi bundle install cost-cap` installs the bundle shipped with chi (`chi bootstrap` installs it with the `core`
1553
+ profile; an existing install gets it with `chi update`). It caps what one session spends, so neither you nor the
1554
+ model has to watch `chi sessions stats`: the harness watches the money and the model never sees any of it (no tool,
1555
+ no steer, no prompt text). It is inert without a cap, and there is none by default.
1556
+
1557
+ ```yaml
1558
+ # config.yml
1559
+ bundles:
1560
+ cost-cap:
1561
+ default: 5.00 # USD cap for a session with none set; absent/0/off: no cap
1562
+ child: 2.00 # for delegate children; absent: default
1563
+ warn_at: 0.8 # tell the user (only) once at this share of the cap; 0/off: no warning
1564
+ ```
1565
+
1566
+ - **What counts:** `ctx.cost`'s total: what the provider reported (`usage.cost`), else the estimate from the
1567
+ model's configured price (`hosts.<name>.models.<id>.price`), plus side requests (the idle recap, `/btw`,
1568
+ `ask_model`), including what earlier workers of the session spent.
1569
+ - **When it checks:** before each generation, so before the money is spent. A generation that crosses the cap is
1570
+ caught at the start of the next one: the overshoot is at most one generation (plus any side requests between).
1571
+ A turn whose last generation crossed it ends normally, and the next turn's first generation asks.
1572
+ - **At `warn_at` of the cap** you get one notice, `cost-cap> $2.41 of $3.00 spent (80%)`; a raised cap warns again.
1573
+ - **At the cap** the turn pauses on a question (header "Cost cap"): `Cost cap reached: $3.04 spent of $3.00.
1574
+ Continue raises it to $6.00.` with **Continue** (another cap's worth: $3 → $6 → $9, as many steps as it takes to
1575
+ pass the spend, saved for the session) and **Stop** (the turn stops, `canceled` with `stopped_by: cost-cap`, and a
1576
+ 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.
1579
+ - **A host it can't watch:** a model that reports no cost and has no configured price (a local server, a free
1580
+ model that reports 0) reads unknown: one notice, `the $3.00 cap can't be enforced: <model> reports no cost and has
1581
+ no price`, and it never stops for an unknown cost. A session with some priced generations counts the unpriced
1582
+ ones as $0.
1583
+
1584
+ **`/cap`** (works while a turn runs; the next generation reads it):
1585
+
1586
+ - `/cap`: `cost-cap: $1.20 spent of $3.00 (reported $1.10, estimated $0.10, side $0.02)`, or `cost-cap: no cap
1587
+ ($1.20 spent)`, with `(can't be enforced: no cost reported or priced)` when it can't be.
1588
+ - `/cap 5` or `/cap $5`: the cap (and what Continue adds) becomes $5.00 for this session. A cap at or under the
1589
+ spend is allowed: the next generation asks.
1590
+ - `/cap off` (or `/cap 0`): no cap for this session, over the config's `default:`.
1591
+
1592
+ A parent agent starts a session with a cap by sending `/cap N` as its first message (`chi send --new -m '/cap 3'`).
1593
+ At the cap, `chi send --wait` exits 3 with a question of kind `hook` (header "Cost cap"); `chi answer ID --question
1594
+ Q --option Continue` (or `--option 1`) goes on, `--option Stop` stops the turn ([sub-agent.md](sub-agent.md)). A
1595
+ parent may Continue: nothing restricts it, as with the step limit. A background delegate child at its cap waits on
1596
+ its own card and doesn't wake its parent ([sessions.md](sessions.md#delegating)).
1597
+
1598
+ The state is one file per session, `<data_dir>/sessions/<session id>.json` (`{"cap": 3.0, "step": 3.0}`), removed
1599
+ with the session.
1600
+
1545
1601
  ## The check-in bundle
1546
1602
 
1547
1603
  `chi bundle install check-in` installs the bundle shipped with chi (`chi
data/docs/releasing.md CHANGED
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
14
  chi starts.
15
15
  - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
- loop-guard, mcp, skills, slow-commands, agents-md, source-links, github-pr, coordinator, claude-peer, codex-peer, and the profiles core and dev) has its own semver in its
16
+ loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, and the profiles core and dev) has its own semver in its
17
17
  `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
18
  `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
data/docs/sessions.md CHANGED
@@ -253,6 +253,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
253
253
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
254
254
  - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
255
255
  - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
256
+ - **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap": a parent waiting on it (`delegate`, `delegate_result`) gets it back as `status: question` with the `chi answer` commands, `--option Continue` or `--option Stop`. A **background** child (`wait: false`) paused at its cap does not wake its parent: delegate reports skip hook questions (they are the user's, on the child's card) and the approval relay carries approvals only, so the child waits on its card, with the web's bell. The parent sees it at its next `delegate_result`.
256
257
  - **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
257
258
  - The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
258
259
  - How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_approvals` by the parent and again by the child.
data/docs/sub-agent.md CHANGED
@@ -105,6 +105,10 @@ A chi parent's `delegate_result` gets a child's step-limit question back as a
105
105
  `delegate session:` (which drops the question), or report back. It isn't
106
106
  relayed to the parent's user the way approvals are.
107
107
 
108
+ A session at its cost cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)) waits the same way, as a
109
+ question of kind `hook` with header "Cost cap" and options `Continue` and `Stop`: `--option Continue` raises the cap
110
+ by another cap's worth and the turn goes on, `--option Stop` ends it `canceled` with `stopped_by: cost-cap`.
111
+
108
112
  ## Approvals
109
113
 
110
114
  A chi parent (the `delegate` tool) doesn't go through any of this: its
@@ -1,13 +1,15 @@
1
1
  ---
2
2
  name: core
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.60.0"
7
+ # Its members' highest floor (cost-cap's)
8
+ requires_chi: ">= 0.83.0"
8
9
  includes:
9
10
  - loop-guard
10
11
  - check-in
11
12
  - guardrails
12
13
  - slow-commands
13
14
  - agents-md
15
+ - cost-cap
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: cost-cap
3
+ version: 0.1.0
4
+ scope: system
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
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:bc6683af0063b90d6a3d7c2796bccf446043dfb1b3f07c04bd54d22d26199e1d
10
+ # ctx.cost is new in chi 0.83.0
11
+ requires_chi: ">= 0.83.0"
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The cost-cap bundle (docs/plugins.md, The cost-cap bundle): a per-session
4
+ # cap on what the session spends (ctx.cost: reported + estimated, side
5
+ # requests included). Before each generation it compares the spend with
6
+ # the cap: at warn_at of it the user (only) gets one notice, at the cap the
7
+ # turn pauses on a question, Continue (the cap goes up by a step) or Stop
8
+ # (the turn stops, with a card). With no one to ask it stops. The model
9
+ # never sees any of it: no tool, no steer, no prompt text.
10
+ #
11
+ # Settings (config.yml, bundles: cost-cap:), read at each use from
12
+ # ctx.settings, never at load:
13
+ # default: 5.00 USD cap for a session with none set; absent/0/off: none
14
+ # child: 2.00 for delegate children (ctx.delegate?); absent: default
15
+ # warn_at: 0.8 the share of the cap that warns; 0/off: no warning
16
+ #
17
+ # State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
18
+ # 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.
20
+ # The cap is resolved once per session id (ctx.delegate? parses the session
21
+ # file) and kept until /cap or Continue writes a new one.
22
+ require "json"
23
+ require "fileutils"
24
+
25
+ class Plugin
26
+ USAGE = "usage: /cap [N | $N | off] (USD; /cap alone shows the spend)"
27
+ OPTIONS = %w[Continue Stop].freeze
28
+ WARN_AT = 0.8
29
+
30
+ def initialize(_settings)
31
+ @mutex = Mutex.new
32
+ @resolved = nil # {session:, cap:, step:}
33
+ @warned = nil # [session id, cap] the share warning was given for
34
+ @unknown_warned = nil # the session id the "can't be enforced" notice was given for
35
+ end
36
+
37
+ def register(chi)
38
+ chi.on(:before_generation) { |_event, ctx| check(ctx) }
39
+ # Only the "can't be enforced" notice: a turn of one generation (or a
40
+ # session's last) has no next before_generation to give it.
41
+ chi.on(:after_generation) { |_event, ctx| check_known(ctx) }
42
+ chi.command("/cap", "see or set this session's cost cap: /cap, /cap 5, /cap off", anytime: true) do |args, ctx|
43
+ command(args, ctx)
44
+ end
45
+ end
46
+
47
+ # The cap in USD (a positive Float) or nil (none, 0, off, false).
48
+ def self.amount(value)
49
+ number = case value
50
+ when Numeric then value.to_f
51
+ when String then value.strip.delete_prefix("$").then { |s| s.match?(/\A\d+(\.\d+)?\z/) ? s.to_f : nil }
52
+ end
53
+ number&.positive? ? number : nil
54
+ end
55
+
56
+ # Cents, or up to four decimals under $0.10, so a small cap (/cap 0.003)
57
+ # doesn't read "$0.00 spent of $0.00".
58
+ def self.money(value)
59
+ return format("$%.2f", value) if value.zero? || value.abs >= 0.1
60
+
61
+ format("$%.4f", value).sub(/(\.\d\d\d*?)0+\z/, '\1')
62
+ end
63
+
64
+ private
65
+
66
+ def check(ctx)
67
+ resolved = resolve(ctx)
68
+ cap = resolved[:cap]
69
+ return unless cap
70
+
71
+ cost = ctx.cost
72
+ return unenforceable(ctx, resolved[:session], cap) unless cost.known?
73
+
74
+ total = cost.total
75
+ return at_cap(ctx, resolved, cost) if total >= cap
76
+
77
+ share = warn_share(ctx)
78
+ return unless share && total >= share * cap
79
+ return unless first_time?(:@warned, [resolved[:session], cap])
80
+
81
+ ctx.notify("#{money(total)} of #{money(cap)} spent (#{(total / cap * 100).floor}%)", level: :warn)
82
+ end
83
+
84
+ def check_known(ctx)
85
+ resolved = resolve(ctx)
86
+ return unless resolved[:cap]
87
+
88
+ unenforceable(ctx, resolved[:session], resolved[:cap]) unless ctx.cost.known?
89
+ end
90
+
91
+ def unenforceable(ctx, session, cap)
92
+ return unless first_time?(:@unknown_warned, session)
93
+
94
+ ctx.notify("the #{money(cap)} cap can't be enforced: #{ctx.model || "the model"} reports no cost and has no price",
95
+ level: :warn)
96
+ end
97
+
98
+ def at_cap(ctx, resolved, cost)
99
+ cap = resolved[:cap]
100
+ raised = raised_cap(cap, resolved[:step] || cap, cost.total)
101
+ answer = ctx.ask_user(question: "Cost cap reached: #{money(cost.total)} spent of #{money(cap)}. " \
102
+ "Continue raises it to #{money(raised)}.",
103
+ header: "Cost cap", options: OPTIONS)
104
+ if continue?(answer)
105
+ save(ctx, resolved[:session], "cap" => raised, "step" => resolved[:step] || cap)
106
+ ctx.notify("cap raised to #{money(raised)}")
107
+ else
108
+ stop(ctx, cap, cost)
109
+ end
110
+ end
111
+
112
+ # Another step on the cap, as many steps as it takes to pass the spend
113
+ # (a turn that overshot by more than a step asks once, not each step).
114
+ def raised_cap(cap, step, total)
115
+ raised = cap + step
116
+ raised += step while raised <= total
117
+ raised.round(6)
118
+ end
119
+
120
+ def continue?(answer)
121
+ return false unless answer
122
+
123
+ Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
124
+ end
125
+
126
+ def stop(ctx, cap, cost)
127
+ return unless ctx.stop_turn("cost cap reached (#{money(cost.total)} of #{money(cap)})")
128
+
129
+ ctx.card(title: "cost-cap stopped the turn",
130
+ body: "Spent #{breakdown(cost)} of a #{money(cap)} cap.\n\n`/cap N` raises it, `/cap off` removes it.",
131
+ level: :warn)
132
+ end
133
+
134
+ def command(args, ctx)
135
+ arg = args.to_s.strip.downcase
136
+ return show(ctx) if arg.empty?
137
+
138
+ session = ctx.session_id
139
+ return "cost-cap: no session yet" unless session
140
+
141
+ if %w[off 0].include?(arg)
142
+ save(ctx, session, "cap" => nil)
143
+ return "cost-cap: no cap for this session"
144
+ end
145
+ value = arg.match?(/\A\$?\d+(\.\d+)?\z/) ? self.class.amount(arg) : nil
146
+ return USAGE unless value
147
+
148
+ save(ctx, session, "cap" => value, "step" => value)
149
+ "cost-cap: cap set to #{money(value)} (#{money(ctx.cost.total)} spent)"
150
+ rescue StandardError => e
151
+ "cost-cap: #{e.message}"
152
+ end
153
+
154
+ def show(ctx)
155
+ cost = ctx.cost
156
+ cap = resolve(ctx)[:cap]
157
+ return "cost-cap: no cap (#{money(cost.total)} spent)" unless cap
158
+
159
+ line = "cost-cap: #{money(cost.total)} spent of #{money(cap)} (#{parts(cost)})"
160
+ cost.known? ? line : "#{line} (can't be enforced: no cost reported or priced)"
161
+ end
162
+
163
+ def parts(cost) = "reported #{money(cost.reported)}, estimated #{money(cost.estimated)}, side #{money(cost.side)}"
164
+
165
+ def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
166
+
167
+ # The session's cap and step now: the state file's, else child: for a
168
+ # delegate, else default:. Cached per session id.
169
+ def resolve(ctx)
170
+ session = ctx.session_id
171
+ cached = @mutex.synchronize { @resolved }
172
+ return cached if cached && cached[:session] == session
173
+
174
+ resolved = { session: session, **(state(ctx, session) || configured(ctx)) }
175
+ @mutex.synchronize { @resolved = resolved }
176
+ end
177
+
178
+ def state(ctx, session)
179
+ return nil unless session
180
+
181
+ saved = JSON.parse(File.read(state_path(ctx, session)))
182
+ return nil unless saved.is_a?(Hash) && saved.key?("cap")
183
+
184
+ cap = self.class.amount(saved["cap"])
185
+ { cap: cap, step: self.class.amount(saved["step"]) || cap }
186
+ rescue Errno::ENOENT, JSON::ParserError
187
+ nil
188
+ end
189
+
190
+ def configured(ctx)
191
+ settings = ctx.settings
192
+ return { cap: nil, step: nil } unless settings.key?("default") || settings.key?("child")
193
+
194
+ key = settings.key?("child") && ctx.delegate? ? "child" : "default"
195
+ cap = self.class.amount(settings[key])
196
+ { cap: cap, step: cap }
197
+ end
198
+
199
+ def warn_share(ctx)
200
+ settings = ctx.settings
201
+ return WARN_AT unless settings.key?("warn_at")
202
+
203
+ share = settings["warn_at"]
204
+ share.is_a?(Numeric) && share.positive? ? share : nil
205
+ end
206
+
207
+ def save(ctx, session, data)
208
+ path = state_path(ctx, session)
209
+ FileUtils.mkdir_p(File.dirname(path))
210
+ Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
211
+ cap = self.class.amount(data["cap"])
212
+ @mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
213
+ end
214
+
215
+ def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
216
+
217
+ def money(value) = self.class.money(value)
218
+
219
+ # Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
220
+ def first_time?(ivar, key)
221
+ @mutex.synchronize do
222
+ next false if instance_variable_get(ivar) == key
223
+
224
+ instance_variable_set(ivar, key)
225
+ true
226
+ end
227
+ end
228
+ end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.82.0
3
+ version: 0.83.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:
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
4
5
  require_relative "client_id"
5
6
  require_relative "session"
6
7
  require_relative "session_inbox"
@@ -129,7 +130,7 @@ module Samagotchi
129
130
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
130
131
  # and the cursor it was read from (a commit after the model moved it
131
132
  # itself, with delegate_result, leaves it alone).
132
- Report = Data.define(:child_id, :text, :before, :after, :rings) do
133
+ Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
133
134
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
134
135
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
135
136
  end
@@ -142,6 +143,7 @@ module Samagotchi
142
143
  @state_dir = state_dir
143
144
  @session_dir = Session.session_dir(session_id, state_dir: state_dir)
144
145
  @predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
146
+ Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
145
147
  # child id → the Report this turn took for it (rings merged).
146
148
  @taken = {}
147
149
  end
@@ -177,12 +179,16 @@ module Samagotchi
177
179
  end
178
180
 
179
181
  # The turn that took the reports was kept: move each child's cursor on
180
- # (unless something else moved it since: the model's own
181
- # delegate_result), then delete the rings.
182
+ # and delete the rings.
182
183
  def commit
183
184
  @taken.each_value do |report|
184
185
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
185
- cursor == report.before ? report.after : cursor
186
+ next cursor unless cursor.report_claim&.fetch("id", nil) == report.claim_id
187
+
188
+ before = report.before.with(report_claim: nil)
189
+ after = report.after.with(report_claim: nil)
190
+ current = cursor.with(report_claim: nil)
191
+ current == before ? after : current
186
192
  end
187
193
  delete_rings(report.rings)
188
194
  rescue StandardError => e
@@ -193,6 +199,11 @@ module Samagotchi
193
199
 
194
200
  # The turn was rolled back: its rings stay for the next one.
195
201
  def release
202
+ @taken.each_value do |report|
203
+ Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
204
+ cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
205
+ end
206
+ end
196
207
  @taken = {}
197
208
  end
198
209
 
@@ -237,13 +248,19 @@ module Samagotchi
237
248
  timeout: 0, turn: before.turn)
238
249
  return nil unless reportable?(wait)
239
250
 
240
- # A reported end is the follow-up turn's too (DelegateCursor#turn):
241
- # a later wait on it would repeat its reply.
251
+ claim_id = before.report_claim&.fetch("id", nil) || SecureRandom.hex(16)
242
252
  after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
243
- turn: wait.status == :waiting_for_answer ? before.turn : nil)
253
+ turn: wait.status == :waiting_for_answer ? before.turn : nil, report_claim: nil)
244
254
  .with_baseline(ReplyWait.baseline_of(wait.session))
255
+ claim = { "id" => claim_id, "file" => wait.file, "cursor" => after.to_json_hash }
256
+ return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
257
+ state_dir: @state_dir)
258
+
259
+ # A reported end is the follow-up turn's too (DelegateCursor#turn):
260
+ # a later wait on it would repeat its reply.
261
+ after = after.with(report_claim: claim)
245
262
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
246
- Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
263
+ Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
247
264
  rescue ArgumentError
248
265
  nil # the child is gone
249
266
  end
@@ -2581,7 +2581,8 @@ module Samagotchi
2581
2581
  # build and after a rebuild that drops them (SystemPrompt#prompt_files
2582
2582
  # returns [] itself: the || keeps a nil builder safe).
2583
2583
  prompt_files: -> { @prompt_builder&.prompt_files || [] },
2584
- frontend: -> { interface }
2584
+ frontend: -> { interface },
2585
+ cost: -> { @metrics.cost }
2585
2586
  )
2586
2587
  end
2587
2588
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require_relative "../log"
5
5
  require_relative "../paths"
6
+ require_relative "../session_metrics/cost"
6
7
  require_relative "../guardrails/context"
7
8
  require_relative "../idle_client"
8
9
  require_relative "service"
@@ -51,10 +52,11 @@ module Samagotchi
51
52
  # ctx.http call without cancel: uses, nil for none: an init task's own,
52
53
  # else the running turn's for work done for it (the turn's thread, or a
53
54
  # plugin handler: +handler+) until the turn has ended.
55
+ # +cost+ returns the session's spend now (SessionMetrics::Cost).
54
56
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
55
57
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
56
58
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
57
- :cancel_controller, keyword_init: true)
59
+ :cancel_controller, :cost, keyword_init: true)
58
60
 
59
61
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
60
62
  # sent nothing usable. The message says why, for the user.
@@ -140,6 +142,11 @@ module Samagotchi
140
142
  # current_model_only names it
141
143
  def model_key = @host.model_key&.call
142
144
 
145
+ # @return [SessionMetrics::Cost] the session's spend so far, read now:
146
+ # #total, #reported, #estimated, #side (Float USD) and #known?
147
+ # (docs/plugins.md); zero and known with no session
148
+ def cost = @host.cost&.call || SessionMetrics::Cost.zero
149
+
143
150
  # @return [String, nil] the name of the session's toolkit ("full" for
144
151
  # one from before toolkits; nil with no session)
145
152
  def toolkit = @host.toolkit&.call
@@ -181,8 +181,9 @@ module Samagotchi
181
181
  (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
182
182
  baseline && ended == baseline[:last_turn]
183
183
  file = newest_reply(id, state_dir: state_dir)
184
- if file && reply_is_turns?(file, ended)
185
- reply = Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
184
+ path = File.join(reply_dir(id, state_dir: state_dir), file) if file
185
+ if file && reply_is_turns?(path, ended)
186
+ reply = Result.new(status: :done, text: File.read(path), file: file)
186
187
  reply.session = settled(session, id, state_dir: state_dir)
187
188
  return reply
188
189
  end
@@ -194,6 +195,9 @@ module Samagotchi
194
195
  !(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
195
196
  (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
196
197
  baseline && ended == baseline[:last_turn]
198
+ # The turn-specific reply lookup above already found its file, if
199
+ # one exists. Without it, don't use the newest file as a fallback:
200
+ # it could belong to another turn.
197
201
  return no_reply(session, baseline, fresh: true)
198
202
  end
199
203
 
@@ -440,19 +444,13 @@ module Samagotchi
440
444
  cursor.nil? || file > cursor
441
445
  end
442
446
 
443
- # Whether the reply file +file+ is the turn that ended at +ended_at+'s,
444
- # not an earlier turn's: the worker records the ended_at, then writes
445
- # the reply (SessionInbox.write_output: the name's timestamp is the
446
- # write time, at least the ended_at); every earlier turn's file is
447
- # older than the turn's end. The wait and the session run on one
448
- # machine, so the name's local-time read is the writer's. A name that
449
- # doesn't read as a time isn't the turn's.
450
- def reply_is_turns?(file, ended_at)
451
- name = file.to_s
452
- return false if name.length < 23
453
-
454
- Time.parse(ended_at.to_s) <= Time.strptime(name[0, 14], "%Y%m%d%H%M%S") + (name[14, 9].to_f / (10**9))
455
- rescue ArgumentError
447
+ # Whether the reply file at +path+ is the turn that ended at +ended_at+'s,
448
+ # not an earlier turn's: the worker records ended_at before writing the
449
+ # reply, so its mtime is at least the turn's end and earlier replies'
450
+ # mtimes precede it. Unlike the filename, mtime is independent of TZ.
451
+ def reply_is_turns?(path, ended_at)
452
+ File.mtime(path) >= Time.parse(ended_at.to_s)
453
+ rescue ArgumentError, SystemCallError
456
454
  false
457
455
  end
458
456
 
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionMetrics
5
+ # The session's spend so far (SessionMetrics#cost, what a bundle's
6
+ # ctx.cost reads), in USD: +reported+ by providers (usage.cost) and
7
+ # +estimated+ from configured prices (ModelPrice.estimate: a generation
8
+ # is one or the other, never both), each the main loop's and the side
9
+ # requests' together; +side+ is the side requests' share of the total
10
+ # (recap, /btw, ask_model). +known+ is false while generations or side
11
+ # requests ran but none was reported or priced: a cap can't be enforced
12
+ # on such a session. A session with some priced and some unpriced
13
+ # generations reads known (the unpriced ones count 0).
14
+ # Bundles use its methods only, never this constant's name.
15
+ Cost = Data.define(:reported, :estimated, :side, :known) do
16
+ def self.zero = new(reported: 0.0, estimated: 0.0, side: 0.0, known: true)
17
+
18
+ # @param totals [Totals] the session's sums, the running turn folded in
19
+ # @param side [SideTotals]
20
+ # @return [Cost]
21
+ def self.of(totals, side)
22
+ side_reported = side.cost_sum.to_f
23
+ side_estimated = side.cost_estimate_sum.to_f
24
+ reported = totals.cost_sum.to_f + side_reported
25
+ estimated = totals.cost_estimate_sum.to_f + side_estimated
26
+ ran = (totals.prompt_sum + totals.completion_sum).positive? || side.requests.positive?
27
+ new(reported: reported, estimated: estimated, side: side_reported + side_estimated,
28
+ known: !ran || reported.positive? || estimated.positive?)
29
+ end
30
+
31
+ # @return [Float] reported + estimated, side requests included
32
+ def total = reported + estimated
33
+
34
+ def known? = known
35
+ end
36
+ end
37
+ end
@@ -12,6 +12,7 @@ require_relative "model_price"
12
12
  require_relative "session"
13
13
  require_relative "session_metrics/totals"
14
14
  require_relative "session_metrics/model_segment"
15
+ require_relative "session_metrics/cost"
15
16
  require_relative "session_metrics/side_totals"
16
17
  require_relative "side_usage"
17
18
 
@@ -365,6 +366,14 @@ module Samagotchi
365
366
  end
366
367
  end
367
368
 
369
+ # The session's spend now (ctx.cost): its turns' sums with the running
370
+ # turn's finished generations folded in, and its side requests.
371
+ # Safe from any thread.
372
+ # @return [Cost]
373
+ def cost
374
+ @mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
375
+ end
376
+
368
377
  # Event sink. Safe to call from any thread; errors are isolated by the
369
378
  # caller (SessionObserver / TerminalUI) but we still guard internally.
370
379
  # @param event [Hash]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../cancellation_controller"
4
4
  require_relative "../plugin/context"
5
+ require_relative "../session_metrics/cost"
5
6
  require_relative "recorder"
6
7
 
7
8
  module Samagotchi
@@ -48,9 +49,33 @@ module Samagotchi
48
49
  toolkit: -> { recorder[:toolkit] },
49
50
  declared_tools: -> { recorder[:declared_tools] || tool_names.call },
50
51
  use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
51
- cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil }
52
+ cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
53
+ cost: -> { cost(recorder[:cost]) }
52
54
  )
53
55
  end
56
+
57
+ # The parts a test may give bundle.set(cost: {...}).
58
+ COST_PARTS = %i[reported estimated side known].freeze
59
+
60
+ # The SessionMetrics::Cost ctx.cost returns for the recorder's +cost+
61
+ # value (Recorder::DEFAULTS).
62
+ # @return [SessionMetrics::Cost]
63
+ def cost(value)
64
+ value = value.call while value.respond_to?(:call)
65
+ case value
66
+ when nil then SessionMetrics::Cost.zero
67
+ when SessionMetrics::Cost then value
68
+ when Numeric then SessionMetrics::Cost.zero.with(reported: value.to_f)
69
+ when Hash
70
+ unknown = value.keys.map(&:to_sym) - COST_PARTS
71
+ raise ArgumentError, "unknown cost parts: #{unknown.join(", ")}" unless unknown.empty?
72
+
73
+ parts = value.transform_keys(&:to_sym)
74
+ SessionMetrics::Cost.new(reported: parts.fetch(:reported, 0).to_f, estimated: parts.fetch(:estimated, 0).to_f,
75
+ side: parts.fetch(:side, 0).to_f, known: parts.fetch(:known, true))
76
+ else raise ArgumentError, "cost: a number, a Hash or a callable, not #{value.inspect}"
77
+ end
78
+ end
54
79
  end
55
80
 
56
81
  # The hook runtime (Hooks::Runtime: event[:notify] and the other
@@ -32,7 +32,10 @@ module Samagotchi
32
32
  DEFAULTS = {
33
33
  session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
34
34
  model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
35
- prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true
35
+ prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true,
36
+ # ctx.cost: nil (zero), a Float (reported USD), a Hash of the parts
37
+ # (reported:, estimated:, side:, known:) or a callable giving one.
38
+ cost: nil
36
39
  }.freeze
37
40
 
38
41
  attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
@@ -14,8 +14,9 @@ module Samagotchi
14
14
  # of the child's turn the last follow-up went to (Delegate.follow_up),
15
15
  # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
16
  # was reported, or when unknown (a new child, an older worker).
17
- DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn) do
18
- def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil) = super
17
+ # +report_claim+: a report reserved until the parent's turn is settled.
18
+ DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn, :report_claim) do
19
+ def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil, report_claim: nil) = super
19
20
 
20
21
  def self.empty = new
21
22
 
@@ -33,6 +34,13 @@ module Samagotchi
33
34
  end
34
35
 
35
36
  def to_json_hash = to_h.transform_keys(&:to_s).compact
37
+
38
+ def self.from_json_hash(data)
39
+ return empty unless data.is_a?(Hash)
40
+
41
+ new(reply_file: data["reply_file"], last_turn: data["last_turn"], question_id: data["question_id"],
42
+ messages: data["messages"], turn: data["turn"], report_claim: data["report_claim"])
43
+ end
36
44
  end
37
45
 
38
46
  # The parent's cursors, one per child, in <parent dir>/delegates.json, so
@@ -68,6 +76,40 @@ module Samagotchi
68
76
  end
69
77
  end
70
78
 
79
+ # Reserve a report only if the cursor read to build it is still current.
80
+ # @return [DelegateCursor, nil] the reserved cursor, or nil if another path moved it
81
+ def claim_report(parent_id, child_id, expected:, claim:, state_dir:)
82
+ locked(parent_id, state_dir: state_dir) do
83
+ all = read(parent_id, state_dir: state_dir)
84
+ cursor = get_from(all, child_id)
85
+ next nil unless cursor == expected
86
+
87
+ claimed = cursor.with(report_claim: claim)
88
+ all[child_id] = claimed.to_json_hash
89
+ write(parent_id, all, state_dir: state_dir)
90
+ claimed
91
+ end
92
+ end
93
+
94
+ # A new owner can retry reports left reserved by a worker that exited
95
+ # before committing or releasing its turn. No cursor file means no
96
+ # claims: the lock would create the session dir and its lock file, so
97
+ # an empty session would no longer count as empty.
98
+ def clear_report_claims(parent_id, state_dir:)
99
+ return unless File.exist?(path(parent_id, state_dir: state_dir))
100
+
101
+ locked(parent_id, state_dir: state_dir) do
102
+ all = read(parent_id, state_dir: state_dir)
103
+ changed = false
104
+ all.each_value do |data|
105
+ next unless data.is_a?(Hash)
106
+
107
+ changed = true if data.delete("report_claim")
108
+ end
109
+ write(parent_id, all, state_dir: state_dir) if changed
110
+ end
111
+ end
112
+
71
113
  # Add the cursors of +entries+ (child id → cursor fields, as #read
72
114
  # gives them) that the parent has none for; one it has is kept.
73
115
  # @return [Array<String>] the child ids added
@@ -94,11 +136,7 @@ module Samagotchi
94
136
  end
95
137
 
96
138
  def get_from(all, child_id)
97
- data = all[child_id]
98
- return DelegateCursor.empty unless data.is_a?(Hash)
99
-
100
- DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
101
- question_id: data["question_id"], messages: data["messages"], turn: data["turn"])
139
+ DelegateCursor.from_json_hash(all[child_id])
102
140
  end
103
141
 
104
142
  def path(parent_id, state_dir:)
@@ -24,6 +24,7 @@ module Samagotchi
24
24
  # the wait's first look (a fast failure) still ends it. The cursors are
25
25
  # on disk (DelegateCursors), so a respawned worker doesn't repeat a reply.
26
26
  module DelegateWait
27
+ Advance = Data.define(:cursor, :already_reported)
27
28
  TIMEOUT_DEFAULT = 600
28
29
  POLL_INTERVAL = 0.5
29
30
  # Seconds with no worker before the child counts as gone: as long as
@@ -66,6 +67,11 @@ module Samagotchi
66
67
  reports = reports_mode(peers)
67
68
  others = relay && others_for(peers.session_id, child_id, relay, sd)
68
69
  cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
70
+ report_claim = cursor.report_claim
71
+ if report_claim
72
+ cursor = DelegateCursor.from_json_hash(report_claim["cursor"]).with(report_claim: report_claim)
73
+ end
74
+
69
75
  baseline = cursor.baseline
70
76
  left = timeout.to_i
71
77
  outcomes = relayed_outcomes.delete(key) || []
@@ -78,7 +84,13 @@ module Samagotchi
78
84
  left -= monotonic - started
79
85
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
80
86
  outcomes.concat(relayed_outcomes.delete(key) || [])
81
- advance(peers.session_id, child_id, wait, state_dir: sd)
87
+ movement = advance(peers.session_id, child_id, wait, state_dir: sd)
88
+ if movement&.already_reported
89
+ file = movement.cursor.report_claim&.fetch("file", nil) || wait.file
90
+ return already_reported_result(child_id, file, state_dir: sd)
91
+ end
92
+ return already_reported_result(child_id, report_claim["file"], state_dir: sd) if report_claim && wait.status == :timeout
93
+
82
94
  text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
83
95
  return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
84
96
  end
@@ -159,15 +171,37 @@ module Samagotchi
159
171
  # was reported; a question keeps it. Nothing moves on a timeout or a
160
172
  # cancel.
161
173
  # @param wait [ReplyWait::Result]
162
- # @return [DelegateCursor, nil] the new cursor
174
+ # @return [Advance, nil] the cursor and whether another path reported it
163
175
  def advance(parent_id, child_id, wait, state_dir:)
164
176
  return nil unless REPORTED.include?(wait.status) && wait.session
165
177
 
166
- DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
167
- cursor = cursor.with(reply_file: wait.file) if wait.status == :done
168
- cursor = cursor.with(turn: nil) unless wait.status == :waiting_for_answer
169
- cursor.with_baseline(ReplyWait.baseline_of(wait.session))
178
+ already_reported = false
179
+ cursor = DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |current|
180
+ if (current.report_claim && current.report_claim["file"] == wait.file) ||
181
+ (wait.status == :done && current.reply_file == wait.file)
182
+ already_reported = true
183
+ next current
184
+ end
185
+
186
+ current = current.with(reply_file: wait.file) if wait.status == :done
187
+ current = current.with(turn: nil) unless wait.status == :waiting_for_answer
188
+ current.with_baseline(ReplyWait.baseline_of(wait.session))
170
189
  end
190
+ Advance.new(cursor: cursor, already_reported: already_reported)
191
+ end
192
+
193
+ def already_reported_result(child_id, file, state_dir:)
194
+ path = if file.to_s.empty?
195
+ nil
196
+ else
197
+ File.join(ReplyWait.reply_dir(child_id, state_dir: state_dir), file)
198
+ end
199
+ text = if path
200
+ "session: #{child_id}\nstatus: reported\nThe reply was already reported to this session (file: #{File.basename(file)})."
201
+ else
202
+ "session: #{child_id}\nstatus: reported\nThe child's latest status was already reported to this session."
203
+ end
204
+ RefText.wrap(text, path)
171
205
  end
172
206
 
173
207
  # The tool result for how the wait ended.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.82.0"
4
+ VERSION = "0.83.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
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.82.0
4
+ version: 0.83.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -200,6 +200,8 @@ files:
200
200
  - lib/samagotchi/bundles/coordinator/plugin.rb
201
201
  - lib/samagotchi/bundles/coordinator/skill_coordinator.md
202
202
  - lib/samagotchi/bundles/core/manifest.yml
203
+ - lib/samagotchi/bundles/cost-cap/manifest.yml
204
+ - lib/samagotchi/bundles/cost-cap/plugin.rb
203
205
  - lib/samagotchi/bundles/dev/manifest.yml
204
206
  - lib/samagotchi/bundles/github-pr/manifest.yml
205
207
  - lib/samagotchi/bundles/github-pr/plugin.rb
@@ -469,6 +471,7 @@ files:
469
471
  - lib/samagotchi/session_inbox.rb
470
472
  - lib/samagotchi/session_manager.rb
471
473
  - lib/samagotchi/session_metrics.rb
474
+ - lib/samagotchi/session_metrics/cost.rb
472
475
  - lib/samagotchi/session_metrics/model_segment.rb
473
476
  - lib/samagotchi/session_metrics/side_totals.rb
474
477
  - lib/samagotchi/session_metrics/totals.rb