samagotchi 0.144.0 → 0.146.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 +57 -1
  3. data/docs/cli.md +1 -1
  4. data/docs/guardrails.md +7 -2
  5. data/docs/internals/bridge.md +3 -1
  6. data/docs/internals/engine.md +1 -1
  7. data/docs/internals/web-frontend.md +24 -3
  8. data/docs/memory.md +45 -0
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +13 -6
  12. data/lib/samagotchi/guardrails/approval.rb +17 -4
  13. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -1
  14. data/lib/samagotchi/guardrails/verdict.rb +5 -3
  15. data/lib/samagotchi/memory_command.rb +135 -7
  16. data/lib/samagotchi/memory_paths.rb +9 -0
  17. data/lib/samagotchi/memory_usage.rb +368 -0
  18. data/lib/samagotchi/relay.rb +2 -2
  19. data/lib/samagotchi/tool_activity.rb +3 -3
  20. data/lib/samagotchi/tool_output_cut.rb +12 -1
  21. data/lib/samagotchi/toolkit_suggest.rb +7 -4
  22. data/lib/samagotchi/tools/delegate.rb +88 -13
  23. data/lib/samagotchi/tools/memory.rb +12 -2
  24. data/lib/samagotchi/version.rb +1 -1
  25. data/lib/samagotchi/web/app.rb +1 -1
  26. data/lib/samagotchi/web/public/app.js +10 -2
  27. data/lib/samagotchi/web/public/composer_fold.js +5 -2
  28. data/lib/samagotchi/web/public/composer_grip.js +45 -2
  29. data/lib/samagotchi/web/public/index.html +8 -6
  30. data/lib/samagotchi/web/public/info_bar_model.js +6 -4
  31. data/lib/samagotchi/web/public/participant_annotate.js +2 -1
  32. data/lib/samagotchi/web/public/question_cards.js +59 -12
  33. data/lib/samagotchi/web/public/revise_stack.js +6 -5
  34. data/lib/samagotchi/web/public/stage_frame.js +19 -7
  35. data/lib/samagotchi/web/public/stage_model.js +2 -1
  36. data/lib/samagotchi/web/public/stage_view.js +39 -24
  37. data/lib/samagotchi/web/public/verify_preset.js +5 -1
  38. data/lib/samagotchi/web/public/worker_features.js +14 -0
  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: 9e6a9b3aacbab7f8ce2c03c1bf67ba3b37ba647db975b340b6e146ee544de0d4
4
- data.tar.gz: 65346bf0d90d4355f7d47e7760d952624cd13b02f2e324ec834649c4a2e12e1d
3
+ metadata.gz: 701bfb63903962a149abf5d17f39be6c043141e5e1b9ea952e7ba27b77ebe3d8
4
+ data.tar.gz: 7be23545775dc31bba230e8dfde6c896def7530d032cc8ae7f79d2a9b91d7782
5
5
  SHA512:
6
- metadata.gz: 536a9cc745170f15f8b52806bc91fa53fc5faa5e0864aa783d8508142ece75626f589fc62974a0883df922671a1543f02cd5c0c8e16d094fd8e8fb3ce94bdd56
7
- data.tar.gz: '06086d99f2b967fff766b9b29dbaae5792450276ef655f3822c58d6d1c844f84cb77737c99ef0b1eeec0ea80316533900d491624450379f9b2100b8f01d39584'
6
+ metadata.gz: 8e4a8bbbfa0398b816a6dd266a6b127f507e6329feaf2b5f37283f2eef76344a334e2bf60409978fab45ab714c56ca1c9c44754174222238d930845bc0f5d929
7
+ data.tar.gz: 139f5254bb1dbeec07cbdf6cb0033cdb34ce5eae967585a9ab9a843fb961528e0d16b77d562668789646b38b16eaca42e28f2338d49f4ebe47b42a58be6f9597
data/CHANGELOG.md CHANGED
@@ -7,6 +7,60 @@ 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.146.0] - 2026-11-03
11
+
12
+ ### Added
13
+
14
+ - `chi memory usage [--scope project|system] [--top N] [--format text|json]` reports what the memories
15
+ cost, counted from the saved sessions: each scope's index tokens against `memory.index_warn_tokens` and
16
+ how many of its lines name a memory no session read, the memories read most by tokens with the share they
17
+ make of all read tokens, and the user's own memories no saved session read. It reads only — no grades, no
18
+ advice to delete, nothing written. [docs/memory.md](docs/memory.md) has the details.
19
+
20
+ ### Changed
21
+
22
+ - A `delegate` child started with no `cwd:` whose task names another checkout of the repository gets a
23
+ `Warning:` line in the tool result naming that checkout and how to start the child there; the child still
24
+ runs in the parent's folder. [docs/sessions.md](docs/sessions.md#delegating)
25
+ - The session option on a file ask says what it covers: "Allow edits to config.yml for the session"
26
+ rather than "Allow this call for the session".
27
+
28
+ ### Fixed
29
+
30
+ - The session bar's status chip follows a question or approval card the moment it is answered,
31
+ instead of holding "waiting" until the next tick while the turn is already running again.
32
+ - A parent agent may give Allow once on a delegate child's `child-boundary` ask under
33
+ `guardrails.parent_answers: soft`. It was refused with a false "changes chi's own config"; an ask
34
+ that really names chi's own files stays yours.
35
+ - A parent agent's Deny on a child's approval says so: the child read "The user declined this call".
36
+ - A delegate child's second report in one parent turn reaches the parent. A reply that came after a
37
+ reported question, while the parent's turn was still running, was lost with nothing left to wake it.
38
+
39
+ Update with `chi update`.
40
+
41
+ ## [0.145.0] - 2026-11-02
42
+
43
+ ### Changed
44
+
45
+ - On a phone, a web approval uses more of the screen, and a short one shows its question, command and Submit without
46
+ scrolling. A tall card can use the stage's raised height, as a long question already could.
47
+ - The stage shrinks back once at the next step after you answer a question or approval, so no empty room stays under the
48
+ answered card. Otherwise it still only grows during a turn.
49
+ - The web's approval card has no Deny radio. The Deny button denies in one tap, and a reason typed in the box now goes
50
+ with it. Terminals, `chi answer` and relayed approvals still answer Deny by its label.
51
+ - On a phone or touch screen, the composer folds while a card's text field has focus and comes back afterwards. Your
52
+ saved fold and draft are untouched.
53
+ - The session bar says "waiting" instead of "running" while a card blocks the turn.
54
+ - A live answer's new line appears at once instead of fading in, which takes about a quarter off the CPU a streaming
55
+ answer uses on a Mac Studio.
56
+
57
+ ### Fixed
58
+
59
+ - An approval card's folder and reason wrap at words instead of splitting them.
60
+ - The text fields on web cards ask iOS Safari not to offer its AutoFill Contact bar.
61
+
62
+ Update with `chi update`.
63
+
10
64
  ## [0.144.0] - 2026-11-02
11
65
 
12
66
  ### Changed
@@ -5526,7 +5580,9 @@ and long-lived sessions.
5526
5580
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5527
5581
  hotkey panel that send selected text or the clipboard to your sessions.
5528
5582
 
5529
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.144.0...HEAD
5583
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.146.0...HEAD
5584
+ [0.146.0]: https://github.com/dm1try/samagotchi/compare/v0.145.0...v0.146.0
5585
+ [0.145.0]: https://github.com/dm1try/samagotchi/compare/v0.144.0...v0.145.0
5530
5586
  [0.144.0]: https://github.com/dm1try/samagotchi/compare/v0.143.0...v0.144.0
5531
5587
  [0.143.0]: https://github.com/dm1try/samagotchi/compare/v0.142.0...v0.143.0
5532
5588
  [0.142.0]: https://github.com/dm1try/samagotchi/compare/v0.141.0...v0.142.0
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)
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
@@ -220,6 +221,23 @@ A new chip on the bar needs its place in that CSS's hidden list and a row in `mo
220
221
  it is lost on a phone. `app.js`'s `updateInfoBar` feeds the sheet (`update`) on every redraw; its rows and actions draw into
221
222
  fixed slots, so a row that joins keeps the focus where it was. The `narrowScreen` listener closes the sheet when the window grows.
222
223
 
224
+
225
+ ## The stage's height
226
+
227
+ `stage_frame.js` is the one writer. The cap is a ceiling: 60% of the page, 80% with the cloud open, 85% while a card has
228
+ the room, and never above the session panel. The frame is grow-only while a turn runs, so a change inside the stage
229
+ never moves its top edge, with one exception: **the next step after a blocking card's answer lets the frame go, once**
230
+ (`stage_view.js releaseHeldWait`, from `generationStarted`). The `held-wait` class says it is due: set with `waiting`,
231
+ cleared there, so a later step, which calls it again, does nothing. What goes together: the live slots' waiting height, a
232
+ raised cap, the tallest content seen (`forgetContent`). At the tap nothing moves: the card collapses to its line inside
233
+ the held frame and the stage's scroll stays, unless the line would be above the view (a reader who scrolled down a card
234
+ taller than the stage), when it comes back to the view's top (`settleAnsweredCard`).
235
+
236
+ Two cards raise the cap: a model's own question whose reply overflows its slot (`drawRoom`, the question room, read from
237
+ the reply's start with a pill to the card) and a blocking approval too tall for the stage's view, a delegate's relayed
238
+ one too (`raiseForApproval`, `stage_frame.js fitsCap`). An approval gets the cap only: no room class and no pill, as it
239
+ opens at its question and command (`revealTop`).
240
+
223
241
  ## Frames
224
242
 
225
243
  During a turn the cost is frames, not code: JS, layout and style are each a few percent of a core, while an animation
@@ -237,6 +255,9 @@ counts. So:
237
255
  it) stops them all, so movement means chi is working; `stage.spec.js`'s question scenario checks it, also after a
238
256
  reload. An asking card (check-in) has no `.waiting`, as its turn goes on: its dot keeps blinking. `.init-spin` is
239
257
  outside the stage (plugin setup only).
258
+ - A one-shot fade on something that arrives many times a second is a recurring cost too. A live answer's new line
259
+ appears at once: its fade alone was about a quarter of the streaming answer's total on the Studio (24-25% of a core to
260
+ 17-19%). The headline's `ts-fade` stays: turning it off saved nothing measurable.
240
261
 
241
262
  A running turn's text reaches the page at most every 100 ms (`turn_block.js FLUSH_MS`): a chunk only marks its step dirty,
242
263
  and `flush` writes the dirty steps, follows and runs `onChange`, which refreshes the stage on the next frame
@@ -295,7 +316,7 @@ reach another device or the server.
295
316
  | Key | Module | What |
296
317
  |---|---|---|
297
318
  | `chi_model`, `chi_model_recent` | `model_pick.js`, `model_picker.js`, `start_page.js` | the start page's model and its recent list |
298
- | `chi_composer_collapsed` | `composer_fold.js` | the composer folded |
319
+ | `chi_composer_collapsed` | `composer_fold.js` | the composer folded (a card's text field folds it for the time it has the focus on a narrow or touch screen, `composer_grip.js watchCardField`, on top of this and of the visit's state: neither is written) |
299
320
  | `chi_composer_floor` | `composer_grip.js`, `composer_mount.js` | the composer's dragged height |
300
321
  | `chi_strip_hidden` (`chi_zen`: the old name, read once) | app.js | the session strip hidden |
301
322
  | `chi_stage_collapsed` | `stage_view.js` | the stage collapsed |
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.144.0
3
+ version: 0.146.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."]