samagotchi 0.145.0 → 0.147.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -1
  3. data/docs/architecture.md +14 -5
  4. data/docs/cli.md +6 -3
  5. data/docs/configuration.md +16 -3
  6. data/docs/guardrails.md +7 -2
  7. data/docs/internals/bridge.md +3 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/web-frontend.md +3 -2
  10. data/docs/memory.md +45 -0
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/child_reports.rb +13 -6
  14. data/lib/samagotchi/guardrails/approval.rb +17 -4
  15. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -1
  16. data/lib/samagotchi/guardrails/verdict.rb +5 -3
  17. data/lib/samagotchi/memory_command.rb +135 -7
  18. data/lib/samagotchi/memory_paths.rb +9 -0
  19. data/lib/samagotchi/memory_usage.rb +368 -0
  20. data/lib/samagotchi/relay.rb +2 -2
  21. data/lib/samagotchi/tool_activity.rb +3 -3
  22. data/lib/samagotchi/tool_output_cut.rb +12 -1
  23. data/lib/samagotchi/toolkit_suggest.rb +7 -4
  24. data/lib/samagotchi/tools/delegate.rb +88 -13
  25. data/lib/samagotchi/tools/memory.rb +12 -2
  26. data/lib/samagotchi/version.rb +1 -1
  27. data/lib/samagotchi/web/app.rb +2 -2
  28. data/lib/samagotchi/web/auto_update.rb +60 -5
  29. data/lib/samagotchi/web/public/app.js +11 -18
  30. data/lib/samagotchi/web/public/participant_annotate.js +2 -1
  31. data/lib/samagotchi/web/public/question_cards.js +33 -9
  32. data/lib/samagotchi/web/public/revise_stack.js +6 -5
  33. data/lib/samagotchi/web/public/update_notice.js +22 -11
  34. data/lib/samagotchi/web/public/update_sheet.js +4 -1
  35. data/lib/samagotchi/web/public/verify_preset.js +5 -1
  36. data/lib/samagotchi/web/public/worker_features.js +14 -0
  37. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  38. data/lib/samagotchi/web/session_hub.rb +17 -4
  39. data/lib/samagotchi/working_set.rb +3 -2
  40. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6055a05533de1a37a9e9393effa720eb2ca93f0db2715d7f806f83cae3086a8d
4
- data.tar.gz: ac0a46f034b62cf4c0b6968d6bb9b07fdf8bae8ab331f4745b4a597ef45dd8c9
3
+ metadata.gz: 29546fb5487086302ed4d68c168fa9e7f4f576bee252e59cfdaf08a4e74081b4
4
+ data.tar.gz: 3c05877e89bccf719dbdf8f4287c8343222f897484c2f22a4c3245bdc92a2af1
5
5
  SHA512:
6
- metadata.gz: 7e0ab9f589c50aa6c3fa7bdb37b64e17d9963191fa1878e624dd33d68f296d31bd107a09eb8feb5ae7bd85b9c7f2e6bbf1ff1c296ae1fd938a828160c7c5a488
7
- data.tar.gz: 34688115cd7554ed4f660ab131dfcccb4dd7d15e24f72c7e32f6802d1a2c422d80cfea3cef47f18afc689eb653fc36102b5726d45129202f157d91f1d1db09b7
6
+ metadata.gz: 953166016279e2b81ebe64c9642df2a245cc3d7fe4520850bf4023557b5d1b7fd620589fd7c49df66d458e59eeae32dffb3020e6286ea8be85edf2710c2bd7d4
7
+ data.tar.gz: 6c01b8ac5e044770f34e4fd5057915116e8b56b81a6f505fac3259449911a02b091262aad2554417ebf0493068ace77b1636d3fb8187769225268d12540c5354
data/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.147.0] - 2026-11-03
11
+
12
+ ### Changed
13
+
14
+ - With `update.auto: install` a newer chi installed some other way, such as `chi update` in a terminal, now gets the
15
+ same unattended restarts as one chi web installed itself: idle workers and chi web move onto it within about 10
16
+ minutes once no turn runs, and the phone hears "chi updated to X". The page offers no **Restart** buttons meanwhile,
17
+ unless the restart failed or has waited an hour; `update.restart: never` keeps them.
18
+ [docs/configuration.md](docs/configuration.md#unattended-updates-updateauto)
19
+
20
+ ### Fixed
21
+
22
+ - An automatic update's sheet no longer offers **Restart workers** and **Restart chi web** beside a run already doing
23
+ both, and its worker restarts no longer stack sticky toasts unless one fails.
24
+
25
+ ## [0.146.0] - 2026-11-03
26
+
27
+ ### Added
28
+
29
+ - `chi memory usage [--scope project|system] [--top N] [--format text|json]` reports what the memories
30
+ cost, counted from the saved sessions: each scope's index tokens against `memory.index_warn_tokens` and
31
+ how many of its lines name a memory no session read, the memories read most by tokens with the share they
32
+ make of all read tokens, and the user's own memories no saved session read. It reads only — no grades, no
33
+ advice to delete, nothing written. [docs/memory.md](docs/memory.md) has the details.
34
+
35
+ ### Changed
36
+
37
+ - A `delegate` child started with no `cwd:` whose task names another checkout of the repository gets a
38
+ `Warning:` line in the tool result naming that checkout and how to start the child there; the child still
39
+ runs in the parent's folder. [docs/sessions.md](docs/sessions.md#delegating)
40
+ - The session option on a file ask says what it covers: "Allow edits to config.yml for the session"
41
+ rather than "Allow this call for the session".
42
+
43
+ ### Fixed
44
+
45
+ - The session bar's status chip follows a question or approval card the moment it is answered,
46
+ instead of holding "waiting" until the next tick while the turn is already running again.
47
+ - A parent agent may give Allow once on a delegate child's `child-boundary` ask under
48
+ `guardrails.parent_answers: soft`. It was refused with a false "changes chi's own config"; an ask
49
+ that really names chi's own files stays yours.
50
+ - A parent agent's Deny on a child's approval says so: the child read "The user declined this call".
51
+ - A delegate child's second report in one parent turn reaches the parent. A reply that came after a
52
+ reported question, while the parent's turn was still running, was lost with nothing left to wake it.
53
+
54
+ Update with `chi update`.
55
+
10
56
  ## [0.145.0] - 2026-11-02
11
57
 
12
58
  ### Changed
@@ -5549,7 +5595,9 @@ and long-lived sessions.
5549
5595
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5550
5596
  hotkey panel that send selected text or the clipboard to your sessions.
5551
5597
 
5552
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.145.0...HEAD
5598
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.147.0...HEAD
5599
+ [0.147.0]: https://github.com/dm1try/samagotchi/compare/v0.146.0...v0.147.0
5600
+ [0.146.0]: https://github.com/dm1try/samagotchi/compare/v0.145.0...v0.146.0
5553
5601
  [0.145.0]: https://github.com/dm1try/samagotchi/compare/v0.144.0...v0.145.0
5554
5602
  [0.144.0]: https://github.com/dm1try/samagotchi/compare/v0.143.0...v0.144.0
5555
5603
  [0.143.0]: https://github.com/dm1try/samagotchi/compare/v0.142.0...v0.143.0
data/docs/architecture.md CHANGED
@@ -335,9 +335,10 @@ reload in a toast, since an open tab keeps its old JS across a `chi web` upgrade
335
335
  `installed`, the newest chi on this machine (`InstalledVersions`: the highest `samagotchi-<v>.gemspec`
336
336
  in RubyGems' specification dirs, or a checkout's `version.rb` on disk; looked at again on each full
337
337
  probe, every 10 s). A `chi` frame and the `GET /api/events` snapshot carry the full state
338
- `{version, installed, available, update_blocked, auto, restart}` (`auto`: `update.auto`, `off` or `install`, which the page's
339
- "available" wording follows; `restart`: `update.restart`, with which the page offers no Restart toast for the
340
- version an automatic install's frames name unless it is `never` or the run failed: that run restarts by itself); `/api/info` and the session-command wake response carry the same fields. `SessionHub#update_available` publishes the checker result under the hub's
338
+ `{version, installed, available, update_blocked, auto, restart, auto_offers}` (`auto`: `update.auto`, `off` or `install`, which the page's
339
+ "available" wording follows; `restart`: `update.restart`; `auto_offers`: the version AutoUpdate says the page should offer
340
+ the restarts for. With `auto: install`, `restart: idle` and no block the page offers no Restart toast for a newer installed
341
+ chi, however it got installed, unless `auto_offers` names it); `/api/info` and the session-command wake response carry the same fields. `SessionHub#update_available` publishes the checker result under the hub's
341
342
  Monitor, but fetching never holds it.
342
343
 
343
344
  `Web::UpdateCheck` runs its own thread beside the hub. When `update.notify: on` (the default) or
@@ -385,7 +386,14 @@ is `update.auto: install`. Every 5 minutes (the first step 60 s after start) it
385
386
  and installs it through `UpdateOps#install(wait: true, auto: true)` when the install isn't blocked, no operation
386
387
  runs, no hub summary has `status: running` or a `pending_question`, and the version isn't in `auto_failed` or
387
388
  already in the `auto` record (one automatic try per version). Its frames carry `auto: true` (the page: "Automatic
388
- chi update in progress", "(automatic)" in the sheet's title). Its record is `update_check.json`'s `auto`:
389
+ chi update in progress", "(automatic)" in the sheet's title, no restart buttons in the sheet, no toast for a worker pass
390
+ unless it fails). A newer chi installed some other way (`AutoUpdate#adopt_installed`: `update.restart: idle`, the same gate, no
391
+ available release to install first, none on record for that version) is **adopted** once seen for `SETTLE` (60 s, in
392
+ practice the next step, as `chi update` merges the bundles after installing the gem and leaves no lock to look at): its
393
+ record starts at `phase: installed` and the run goes on as below. Ahead of every gate each step calls
394
+ `SessionHub#auto_offer_restart` (`auto_offers`, in memory) when a run on record for the newest installed chi is closed with
395
+ this chi web still older, or that chi has waited `HELD_FOR` (1 h): the trying goes on, and the page has its buttons back;
396
+ `restart_failed` and a failed install call it at once. Its record is `update_check.json`'s `auto`:
389
397
  `{version, from, at, kept, phase: installed|workers|web|retrying|done|failed, workers: {restarted, left}, detail, pushed}`,
390
398
  in Ruby the value object `Web::AutoUpdateRecord`, which owns that shape (`from_h`, `to_store`).
391
399
  With `update.restart: never` the run ends at the install (`chi_notice` `installed`). With `idle` it goes on
@@ -395,7 +403,8 @@ the worker's own atomic refusal still applies); then, with no
395
403
  turn running, `Server.restart_preflight` (the restart's command with `--version`, 20 s, killed on a timeout; it must
396
404
  print a chi at least the release) and, if it passes, the record's `phase: web` is written **before**
397
405
  `UpdateOps#restart_web`. A failed preflight, or a refused restart, publishes `restart_failed` and ends the run
398
- (`failed`). `AutoUpdate#stop` waits `STOP_WAIT` (25 s, longer than the preflight's timeout) for its thread, and a
406
+ (`failed`). A run at `installed` or `workers` that finds this chi web already on its version (restarted by hand from the
407
+ page's offer) goes straight to the finish. `AutoUpdate#stop` waits `STOP_WAIT` (25 s, longer than the preflight's timeout) for its thread, and a
399
408
  preflight that ends after a stop leads to nothing (no `web`, no exec): the next start takes the run up again. The restarted chi web's AutoUpdate (first step after 5 s) finds `phase: web`, checks it runs the
400
409
  release, counts the workers once more and publishes `updated`, then `done`, or `retrying` while workers are left. Those are
401
410
  retried each step until none is left or 24 h (`RETRY_FOR`) have passed since the install, under the same filter, and from
data/docs/cli.md CHANGED
@@ -30,7 +30,7 @@
30
30
  - `chi share ID --name NAME [--identity EMAIL] [--agent] [--expires 3d] [--cap 2]`, `chi share ls [ID]`, `chi share cap ID P DOLLARS`, `chi share revoke ID [P] | --all` — invite a participant to a session through chi web on the LAN (read-only review turns in a review session, pair turns that act under your guardrails in a working session) (a link of their own, 7 days, a $2 spending cap), list them, change one's cap, revoke them (see [Participants](#participants))
31
31
  - `chi --remote NAME send|answer|note|context|sessions|share|review …` (or `CHI_REMOTE=NAME`) — those commands on that machine's sessions, through its chi web (see [Remote sessions](#remote-sessions))
32
32
  - `chi --remote NAME --attach ID` (`--resume ID`, `--dir DIR [-p TEXT]`) — the attached TUI on a session of that machine (see [Remote sessions](#remote-sessions))
33
- - `chi memory ls [--scope project|system] [--format text|json]` — the memories of both scopes (this folder's project first), one row each: scope, name, size, date, `from <bundle>`, the index description and its model overlays; `chi memory rm NAME [--scope project|system]` removes one as `memory_write` with `remove: true` does: its file and model overlays go to the bundle trash (the line names the dir; `chi bundle trash` lists and empties it) and its index line is dropped. NAME in both scopes without `--scope` exits 2 naming both; a missing NAME or a bundle's memory (`chi bundle uninstall` removes it) exits 1 (see [Memory: Listing and removing from the shell](memory.md#listing-and-removing-from-the-shell))
33
+ - `chi memory ls [--scope project|system] [--format text|json]` — the memories of both scopes (this folder's project first), one row each: scope, name, size, date, `from <bundle>`, the index description and its model overlays; `chi memory rm NAME [--scope project|system]` removes one as `memory_write` with `remove: true` does: its file and model overlays go to the bundle trash (the line names the dir; `chi bundle trash` lists and empties it) and its index line is dropped. NAME in both scopes without `--scope` exits 2 naming both; a missing NAME or a bundle's memory (`chi bundle uninstall` removes it) exits 1. `chi memory usage [--scope project|system] [--top N] [--format text|json]` reports what the memories cost over the saved sessions: each scope's index tokens and how many of its lines name a memory nobody read, the memories read most by tokens, and the user's own memories no saved session read (`--top` caps the rows per section, 10; no sessions says so and exits 0) — see [Memory: What the memories cost](memory.md#what-the-memories-cost)
34
34
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
35
35
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
36
36
  - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`), and `big memories` the memories over `memory.entry_warn_tokens` ([Memory](memory.md#a-memorys-size)), and `model notes` the model notes a prompt of that model loads (`model_notes_deepseek (system, 612 chars)`, `none`; inside a session its `--mute` list is left out). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
@@ -267,7 +267,9 @@ done
267
267
  in place, the notice gives the blocker and its command instead.
268
268
  With `update.auto: install` chi web installs the release by itself once no
269
269
  turn runs anywhere, then restarts idle workers and itself (`update.restart`),
270
- and the notice reads "installs by itself" with no **Update** button; see
270
+ and the notice reads "installs by itself" with no **Update** button. A newer
271
+ chi installed from a terminal gets those restarts too, and no **Restart**
272
+ buttons unless the restart failed or has waited an hour; see
271
273
  [Unattended updates](configuration.md#unattended-updates-updateauto).
272
274
 
273
275
  - **The chi home.** With a [chi home](configuration.md#the-chi-home), a `chi
@@ -355,7 +357,8 @@ done
355
357
  terminal, no reminder) and `session.idle_exit_minutes` pass (never with 0).
356
358
  A new or woken worker always starts on the newest chi installed. A running
357
359
  `chi web` (on the port it was started with, else `web.port`) on an older chi needs a restart (Ctrl-C, then `chi web`):
358
- its page says so too. One from before workers started on the newest chi
360
+ its page says so too. With `update.auto: install` it restarts by itself once no turn
361
+ runs ([Unattended updates](configuration.md#unattended-updates-updateauto)). One from before workers started on the newest chi
359
362
  also starts its new sessions on its own version until then (the row says so).
360
363
 
361
364
  `--dry-run` shows the table with "would update" and changes nothing. It is
@@ -2163,9 +2163,22 @@ Both are read when chi web starts (restart it to apply a change). What it does,
2163
2163
  ([Phone notifications](#phone-notifications-web-push)); without it the page's toasts and
2164
2164
  `$XDG_STATE_HOME/samagotchi/update_check.json` (`auto`) are the record.
2165
2165
 
2166
+ **A chi installed some other way** (`chi update` in a terminal, a release newer than `update.min_age_hours` allows, a
2167
+ hand `gem install`) gets steps 3 and 4 too, with `update.restart: idle` and an install that isn't blocked: chi web notices it,
2168
+ waits for the next 5-minute look (`chi update` merges the bundles after installing the gem) and for the quiet moment of
2169
+ step 1, then restarts as above, so chi web is on it within about 10 minutes when nothing runs. A release it can install
2170
+ goes first. One run per version here too: a restart that didn't take isn't tried again.
2171
+
2172
+ The page offers no **Restart** buttons while chi web is going to restart by itself. They come back, as a toast, when it
2173
+ won't (a failed run) or when something has held it for an hour (a forgotten question, a long turn); it keeps trying behind
2174
+ them. Worker restarts in such a run show no toast unless one fails, and the sheet behind **View** has only **Close**;
2175
+ the session's own **Restart** in its bar is always there.
2176
+
2166
2177
  There is no watchdog: chi web restarts into the newest chi it just proved can start, and nothing brings an old one
2167
- back by itself. To roll back, install the older gem with `gem install samagotchi -v X` and restart chi web
2168
- (`chi update --to` refuses a version that isn't newer than the running chi). The desktop helper isn't rebuilt (that
2178
+ back by itself. To roll back, set `update.auto: off` (or the next check installs the release again), uninstall the newer
2179
+ gem with the chi home's Ruby (`gem uninstall samagotchi -v Y`: chi always starts the newest one installed, so installing an
2180
+ older gem beside it changes nothing), and restart chi web. `chi _X_ web` stays on X only with `update.auto: off`: a restart
2181
+ starts the newest. `chi update --to` refuses a version that isn't newer than the running chi. The desktop helper isn't rebuilt (that
2169
2182
  stays a manual `chi update`).
2170
2183
 
2171
2184
  ## All settings
@@ -2288,7 +2301,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2288
2301
  | `update.bundles` | `true` | | `false`: `chi update` leaves the shipped bundles to `chi bundle upgrade` (`--no-bundles`). |
2289
2302
  | `update.desktop` | `true` | | `false`: `chi update` leaves the desktop helper alone (`--no-desktop`). |
2290
2303
  | `update.notify` | `on` | | `on`: chi web checks RubyGems once a day and shows aged stable releases newer than both this chi and the newest installed chi; `off`: no thread or network check. |
2291
- | `update.auto` | `off` | | `install`: chi web installs the release its checker found (aged by `update.min_age_hours`) once no turn runs and no question waits in any session, as the **Update** button would (shipped bundles merged, your edits kept and reported), then pushes how it went; `update.restart` says what follows. An install that fails is tried once per version (a newer release, or the **Update** button, tries again). The check runs even with `update.notify: off` (which then only silences the page's and the phone's "available"). An install that can't run here (a checkout, Bundler, an unwritable gem dir) stays a notice. `off`: notice only. See [Unattended updates](#unattended-updates-updateauto). |
2304
+ | `update.auto` | `off` | | `install`: chi web installs the release its checker found (aged by `update.min_age_hours`) once no turn runs and no question waits in any session, as the **Update** button would (shipped bundles merged, your edits kept and reported), then pushes how it went; `update.restart` says what follows, also for a chi installed some other way (`chi update` in a terminal). An install that fails is tried once per version (a newer release, or the **Update** button, tries again). The check runs even with `update.notify: off` (which then only silences the page's and the phone's "available"). An install that can't run here (a checkout, Bundler, an unwritable gem dir) stays a notice. `off`: notice only. See [Unattended updates](#unattended-updates-updateauto). |
2292
2305
  | `update.restart` | `idle` | | With `update.auto: install`: `idle` restarts the stale workers whose session is idle and untouched for 10 minutes, then chi web itself, once no turn runs anywhere and the newest chi has answered `chi --version`; it pushes "chi updated to X". `never`: the install only (the page's toast offers the restarts, workers also exit on their own when idle). |
2293
2306
  | `update.check_hours` | `24` | | Hours between RubyGems checks (at least 1). The first check starts about 30 seconds after chi web starts. |
2294
2307
  | `update.min_age_hours` | `24` | | A release must be this old before chi web offers it; `0` disables the age delay. |
data/docs/guardrails.md CHANGED
@@ -81,7 +81,8 @@ Who answers:
81
81
 
82
82
  The model gets one line on a deny. A rule's or hook's deny reads
83
83
  `[execute] Error: denied by guardrail (rule git-push, bundle guardrails): git push publishes commits. The user was not asked. Do not retry it or reach the same result another way; ask the user how to proceed.`
84
- When the user picks Deny on an ask, it leads with the user's answer:
84
+ When the user picks Deny on an ask, it leads with the user's answer (a parent
85
+ agent's Deny says `The parent agent declined this call`):
85
86
  `[execute] Error: The user declined this call: "open a PR instead". It needed approval (rule git-push, bundle guardrails): git push publishes commits. Do not retry it or reach the same result another way; ask the user how to proceed.`
86
87
 
87
88
  ## Approvals
@@ -91,7 +92,7 @@ Allowing beyond "once" is stored in `$XDG_STATE_HOME/samagotchi/guardrails/appro
91
92
 
92
93
  | Scope | Allows |
93
94
  |---|---|
94
- | session | this exact call (tool + command, or paths) in this session |
95
+ | session | this exact call in this session: the tool + command, or for a file tool its paths, so its label names them (`Allow edits to config.yml for the session`) and a later edit of that file needs no ask |
95
96
  | repo | this exact call in this repo (the cwd outside a repo), any session |
96
97
  | rule | anything this rule asks about in this repo |
97
98
 
@@ -234,6 +235,10 @@ other user-only rules (`chi-context-cmd`, `chi-broadcast`,
234
235
  `prompt-memory-write`, `model-overlay-write`, and `chi-answer-piped` /
235
236
  `chi-answer-http`, which answer a grandchild's question as the user).
236
237
 
238
+ `child-boundary` isn't one of them: an ask before a delegate child acts outside
239
+ its folder follows the level, so a `soft` parent may give Allow once. If that
240
+ call names chi's own files, it is the user's as above.
241
+
237
242
  A plugin's or a hook's question (`ask_user`) tags its options the same way
238
243
  (`effects:`, see [Plugins](plugins.md)); an untagged one is a plain `answer`.
239
244
 
@@ -66,7 +66,9 @@ the "asking again" and steer-cut rows, ✂ rows and questions (with how they wer
66
66
  One per `GET /session/:id/stream` connection, and itself that connection's observer: `Engine#emit_event` calls
67
67
  `#call` on the turn's thread, so it only pushes onto a `BoundedQueue` (when full, the oldest entry goes and an
68
68
  overflow flag is set). The connection's own thread (`#serve!`) drains the queue onto the socket, so a slow client
69
- cannot stall a turn or another subscriber. `serve!` orders a join so that nothing is missed or doubled:
69
+ cannot stall a turn or another subscriber. An event holding bytes JSON can't take goes out scrubbed
70
+ (`SSEWriter#frame_json`, `LLM::Utf8Scrub`): a raise there would end this stream, and every later one for as long as the
71
+ ring holds the event. `serve!` orders a join so that nothing is missed or doubled:
70
72
 
71
73
  1. subscribe the live queue first;
72
74
  2. capture `event_count` (the snapshot seq);
@@ -805,7 +805,7 @@ Both loops:
805
805
  guessed from the block and it never enters the history (`:generation_completed`
806
806
  and `:after_generation` have already seen the text): `:generation_retrying` with `restarted` voids the streamed text, `:empty_answer_retry` with
807
807
  `malformed` goes out, and a hidden `TurnNote.malformed_retry` asks again at the retry temperature. It has its own budget, so `retry.empty_answer: 0`
808
- doesn't turn it off, and a real tool call resets it. Two in a row fail the turn with `LLM::MalformedGeneration`, keeping its earlier steps. Each is
808
+ doesn't turn it off, and a real tool call resets it. Two in a row fail the turn with `LLM::MalformedGeneration`, keeping its earlier steps; a Stop that lands on a stray reply ends the turn cancelled, not failed, because a stopped turn skips this path. Each is
809
809
  logged as `generation_malformed` with the shape, finish reason, served model and the text's tail. `KernelLoop` has its own path (`ToolCallParser#malformed`).
810
810
  - **Empty-answer retry** (`EmptyAnswerRetry`): a generation with no visible text and no tool calls is asked
811
811
  again, up to `retry.empty_answer` times (at most 3), with a hidden nudge (`TurnNote.empty_retry`) on the tail.
@@ -23,7 +23,7 @@ page. A factory dep a new version stops passing is the same hazard the other way
23
23
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
24
24
  until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
25
25
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
26
- `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js` and `more_sheet.js` are new modules for that reason.
26
+ `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep) and `worker_features.js` are new modules for that reason.
27
27
 
28
28
  ## Two pages, two models
29
29
 
@@ -152,7 +152,8 @@ restart's hub event that brings the feature enables it, the worker going (`worke
152
152
  it's unavailable. `forgetSession` and `renderSessionView` draw the history with the answer already, so they `prime()`
153
153
  the gates in the batch of their write, and nothing fires. Today two gates: `verify` (Verify fresh on a verdict's
154
154
  card, `redrawFresh`) and `revise` (the answers' Revise buttons and the composer's Revising chip, `redrawRevise`).
155
- Both refuse only a live worker without the feature: a worker that isn't up is woken by the send as a new one, which
155
+ Both refuse only a live worker without the feature — `workerPredates(features, up, feature)` in
156
+ `worker_features.js`, the rule both gates share: a worker that isn't up is woken by the send as a new one, which
156
157
  has it. A new action that needs a worker feature is one more entry.
157
158
 
158
159
  ### Exceptions
data/docs/memory.md CHANGED
@@ -229,6 +229,8 @@ or trims them; chi measures them (tokens estimated as characters /
229
229
  `system ~2.0k tokens (54 lines), project ~1.4k tokens (35 lines)`, with
230
230
  `, over 2500` inside the parentheses for a scope over
231
231
  `memory.index_warn_tokens`.
232
+ - `chi memory usage` counts what the memories cost over the saved sessions: each scope's index tokens,
233
+ the memories read most, and the ones no session read (see [What the memories cost](#what-the-memories-cost)).
232
234
  - `/stats` has `memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`,
233
235
  and the web's ctx tooltip (info bar and session card) the same line. It is
234
236
  what this session's prompt holds, measured when the prompt was built (after
@@ -282,6 +284,49 @@ every read. `chi self` has a `big memories` row instead: the memories over the l
282
284
  `config_modification_protocol` is over on every install; the fix is upstream's). This is how a
283
285
  memory already over the limit gets seen.
284
286
 
287
+ ## What the memories cost
288
+
289
+ `chi memory usage [--scope project|system] [--top N] [--format text|json]` answers
290
+ "where do my memories cost the most?" from the saved session files. It reads only: no
291
+ writes, no model call, no grades, and no advice to delete anything. `chi self`'s `big memories`
292
+ row still lists memories over `memory.entry_warn_tokens`; this is the other half — cost is
293
+ size times reads, and a memory of 2k tokens read fifty times costs more than one of 8k read once.
294
+
295
+ - **The header** says how many sessions were counted and the days they span, because retention
296
+ limits what can be seen: 14 days and 500 sessions by default, so "not read" means "not read
297
+ in the sessions still saved". Archived sessions count; test runs and sessions with no user
298
+ message do not, and the header says how many of those were left out. With nothing counted at
299
+ all, the line says what is in the dir instead of "nothing saved": the files that could not be
300
+ read and the sessions not counted.
301
+ - **The indexes come first**, since both are sent with every prompt: their tokens (the same
302
+ figure `chi self` and `/stats` show), the `memory.index_warn_tokens` limit, and how many of
303
+ their *memory* lines name a memory no counted session read. The index's heading and a bold
304
+ line of prose are not memory lines, so they sit in neither the count nor its denominator.
305
+ - **Most read, by tokens**: name, tokens, reads, the memory's size now (`-` when the line has no
306
+ file behind it), its scope, and `from <bundle>` for a bundle's memory (which the user cannot
307
+ tighten in place). The header of the section says what share the shown rows make of all read
308
+ tokens (`--top` caps the rows, 10 by default, at most 1000). If every read is of a name with
309
+ no memory now, the section says that instead of listing rows.
310
+ - **Not read in saved sessions**: the count of the user's own memories no counted session read,
311
+ then the largest of them. A bundle's memories are not listed, and a memory the session was
312
+ started with (`--memory`, or config.yml's `memories:`) counts as used: the model has it in the
313
+ prompt without a read. A line says an index line is paid whether or not the memory is read, so
314
+ a never-read memory still costs its line.
315
+ - Names read that no longer have a memory behind them are counted in one line, not as rows.
316
+
317
+ A read is a `memory_read`, or a `read` of a memory's file, in either loop's saved shape (the chat
318
+ loop's calls paired by `tool_call_id`, the native loop's call markup with one joined result). Its
319
+ cost is the size of the result as it was saved — a cut result counts as it happened, a model overlay
320
+ too, and `read`ing an overlay's file is a read of the memory it overlays — in tokens as the prompt
321
+ estimates them (characters / `context.chars_per_token`). A comma list of names splits that one result
322
+ by share, over every name asked for: `index` is not a row, but it is one of the names the share is
323
+ worked out over. A project memory is credited only with the reads of
324
+ sessions of this folder's project (worktrees of one repository share it); a system memory with every
325
+ session's, whichever project they came from.
326
+
327
+ Reads saved before whole reads hold cut results; when any counted read does, one line under "Most
328
+ read" says so, since a big memory may cost more per read now than those numbers show.
329
+
285
330
  ## Skills
286
331
 
287
332
  A **skill** is a memory named `skill_<name>` that holds the steps of a
data/docs/sessions.md CHANGED
@@ -452,7 +452,7 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
452
452
  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-output-limits.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; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; 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.
453
453
 
454
454
  - `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting. With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to: its running turn, which it merges into, or the turn of its own it runs when the child is idle (`turn` in `delegates.json`, set when the child's worker names `turn_ids`), not a newer reply of a turn queued behind it. A follow-up merged as a steer into a turn the child started at that moment (a wake, a reminder, another message) gets that turn's reply or question. A child on its first turn, or with an older worker, gives its next reply, as before.
455
- - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's.
455
+ - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, or a path with spaces. The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
456
456
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one. After `wait: false` it isn't needed: the report brings the reply.
457
457
  - A wait also ends early when a person's message waits for the parent's turn's next step (the `task_wait` rule: `Engine#input_waiting?`, `SessionInbox.steer_waiting?`, never in a participant's turn): `ReplyWait`'s `message_waiting:` probe gives `:message_waiting`, checked after every outcome the child can arrive at (a reply, a question, an end, a gone worker) and a cancel, before the deadline, and the result says `no reply yet: a message from the user is waiting; the child keeps running. Read the message first.` with the same last line as a timeout's. The child is never touched, the cursor doesn't move (the reply still comes, as a report or at the next `delegate_result`), a relayed approval's outcome line stays in the result, and a reserved report reads as for a timeout. A delegate child's report doesn't end a wait. This holds for `delegate`'s own wait too (both go through `DelegateWait`).
458
458
  - 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`.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.145.0
3
+ version: 0.147.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:
@@ -312,12 +312,12 @@ module Samagotchi
312
312
  own + strays
313
313
  end
314
314
 
315
- # A second ring for a child taken earlier in this turn: read from where
316
- # the first report left the cursor, keeping the first one's +before+.
315
+ # A second ring for a child taken earlier in this turn: this report's
316
+ # cursors (#report_for), both reports' rings.
317
317
  def merge_taken(earlier, report)
318
318
  return report unless earlier
319
319
 
320
- report.with(before: earlier.before, rings: earlier.rings + report.rings)
320
+ report.with(rings: earlier.rings + report.rings)
321
321
  end
322
322
 
323
323
  # @return [Report, nil] nil when the child has nothing to report
@@ -325,7 +325,14 @@ module Samagotchi
325
325
  child = Session.load(child_id, state_dir: @state_dir)
326
326
  return nil unless child.parent_id == @session_id
327
327
 
328
- before = @taken[child_id]&.after || Tools::DelegateCursors.get(@session_id, child_id, state_dir: @state_dir)
328
+ current = Tools::DelegateCursors.get(@session_id, child_id, state_dir: @state_dir)
329
+ earlier = @taken[child_id]
330
+ # Until the turn commits, the file holds the cursor from before the
331
+ # first report, plus its claim. A second ring reads on from where that
332
+ # report left it, and a commit moves on from the file's. If the model
333
+ # moved the cursor meanwhile (delegate_result), the file's is the truth.
334
+ kept = earlier && current.without_claim == earlier.before.without_claim ? earlier : nil
335
+ before = kept ? kept.after : current
329
336
  # A follow-up's turn (DelegateCursor#turn) is reported by its own
330
337
  # reply, as delegate_result takes it, not a newer queued turn's.
331
338
  wait = ReplyWait.call(child_id, state_dir: @state_dir, cursor: before.reply_file, baseline: before.baseline,
@@ -337,14 +344,14 @@ module Samagotchi
337
344
  turn: wait.status == :waiting_for_answer ? before.turn : nil)
338
345
  .with_baseline(ReplyWait.baseline_of(wait.session))
339
346
  claim = Tools::ReportClaim.new(id: claim_id, file: wait.file, cursor: after)
340
- return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
347
+ return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: current, claim: claim,
341
348
  state_dir: @state_dir)
342
349
 
343
350
  # A reported end is the follow-up turn's too (DelegateCursor#turn):
344
351
  # a later wait on it would repeat its reply.
345
352
  after = after.with(report_claim: claim)
346
353
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
347
- Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id,
354
+ Report.new(child_id: child_id, text: text, before: kept ? kept.before : before, after: after, rings: rings, claim_id: claim_id,
348
355
  relay: relay?(wait) ? wait.question : nil)
349
356
  rescue ArgumentError
350
357
  nil # the child is gone
@@ -181,16 +181,17 @@ module Samagotchi
181
181
  return verdict.settle!(:deny, decided_by: "user", note: "The approval was cancelled.")
182
182
  end
183
183
 
184
+ who = answer.parent_agent? ? Verdict::PARENT_AGENT : "user"
184
185
  index = answer.selected_indices.first
185
186
  if index && index < scopes.size
186
- verdict.settle!(:allow, decided_by: "user")
187
+ verdict.settle!(:allow, decided_by: who)
187
188
  verdict.scope = scopes[index]
188
189
  return verdict
189
190
  end
190
191
 
191
192
  freeform = answer.freeform.to_s.strip
192
- note = freeform.empty? ? "The user declined this call." : "The user declined this call: #{freeform.inspect}."
193
- verdict.settle!(:deny, decided_by: "user", note: note)
193
+ declined = "The #{who} declined this call"
194
+ verdict.settle!(:deny, decided_by: who, note: freeform.empty? ? "#{declined}." : "#{declined}: #{freeform.inspect}.")
194
195
  end
195
196
 
196
197
  # The rule's scopes; "rule" only when a rule id names what to approve.
@@ -206,12 +207,24 @@ module Samagotchi
206
207
  place = verdict.targets&.repo_root ? "in this repo (#{verdict.targets.repo_name})" : "in this directory"
207
208
  case scope
208
209
  when "once" then ONCE
209
- when "session" then "Allow this call for the session"
210
+ when "session" then "Allow #{session_subject(verdict.targets)} for the session"
210
211
  when "repo" then "Allow this call #{place}"
211
212
  when "rule" then "Allow rule #{verdict.rule} #{place}"
212
213
  end
213
214
  end
214
215
 
216
+ # What a session answer covers, as Approvals.key_for keys it: a file
217
+ # tool's later calls on the same paths, else this one exact call.
218
+ def session_subject(targets)
219
+ return "this call" if targets.nil? || targets.command || targets.paths.empty?
220
+
221
+ what = targets.paths.one? ? File.basename(targets.paths.first) : "#{targets.paths.size} files"
222
+ "#{SESSION_VERBS.fetch(targets.tool, "#{targets.tool} on")} #{what}"
223
+ end
224
+
225
+ # How a session label names a file tool's calls.
226
+ SESSION_VERBS = { "edit" => "edits to", "write" => "writes to" }.freeze
227
+
215
228
  # How much of a plugin tool's arguments the question shows.
216
229
  ARGS_CHARS = 300
217
230
 
@@ -40,6 +40,10 @@ module Samagotchi
40
40
  # The guardrails bundle's ask before a memory removal: its paths are in
41
41
  # the config dir, yet a parent may answer it.
42
42
  MEMORY_REMOVE_RULE = "memory-remove"
43
+ # A delegate child's ask before it acts outside its folder. It carries
44
+ # the core source but isn't about chi's own files, so it follows the
45
+ # level like any other ask, unless it names them (#chi_files?).
46
+ CHILD_BOUNDARY_RULE = "child-boundary"
43
47
  # The guardrails bundle's shell-touches-chi, for a word it can't
44
48
  # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
45
49
  CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/(?:modules/\S+/)?hooks})
@@ -68,7 +72,7 @@ module Samagotchi
68
72
  facts = ApprovalFacts.from_h(facts)
69
73
  return false if facts.nil?
70
74
  return true if PROTECTED_RULES.include?(facts.rule.to_s)
71
- return true if facts.source.to_s == ProtectedPaths::SOURCE
75
+ return true if facts.source.to_s == ProtectedPaths::SOURCE && facts.rule.to_s != CHILD_BOUNDARY_RULE
72
76
  # A memory's file sits in the config dir, but removing one is the
73
77
  # memory-remove rule's ask, which a parent may answer.
74
78
  return false if facts.rule.to_s == MEMORY_REMOVE_RULE
@@ -26,6 +26,8 @@ module Samagotchi
26
26
  PROTECTED_RULES = %w[chi-config chi-home chi-shim chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
27
27
  model-overlay-write chi-answer-piped chi-answer-http].freeze
28
28
  SCOPES = %w[once session repo rule].freeze
29
+ # Who answered an ask when it was a parent agent, not the user.
30
+ PARENT_AGENT = "parent agent"
29
31
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
30
32
 
31
33
  attr_reader :decision, :reason, :rule, :source, :scopes, :decided_by
@@ -118,12 +120,12 @@ module Samagotchi
118
120
 
119
121
  # What the model gets for a deny (after the "[tool] Error: " prefix):
120
122
  # who decided, why, and not to route around it (or the voter's own
121
- # advice). A user's deny leads with the user's answer, so it doesn't
122
- # read as the rule refusing.
123
+ # advice). A deny on an ask leads with the answer, the user's or a
124
+ # parent agent's, so it doesn't read as the rule refusing.
123
125
  def deny_text
124
126
  reason = @reason.to_s.strip
125
127
  reason += "." unless reason.empty? || reason.match?(/[.!?]\z/)
126
- parts = if @decided_by == "user" && @note
128
+ parts = if ["user", PARENT_AGENT].include?(@decided_by) && @note
127
129
  [@note, "It needed approval (#{decider}):", reason]
128
130
  else
129
131
  ["denied by guardrail (#{decider}):", reason, @note || "The user was not asked."]