samagotchi 0.145.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6055a05533de1a37a9e9393effa720eb2ca93f0db2715d7f806f83cae3086a8d
4
- data.tar.gz: ac0a46f034b62cf4c0b6968d6bb9b07fdf8bae8ab331f4745b4a597ef45dd8c9
3
+ metadata.gz: 701bfb63903962a149abf5d17f39be6c043141e5e1b9ea952e7ba27b77ebe3d8
4
+ data.tar.gz: 7be23545775dc31bba230e8dfde6c896def7530d032cc8ae7f79d2a9b91d7782
5
5
  SHA512:
6
- metadata.gz: 7e0ab9f589c50aa6c3fa7bdb37b64e17d9963191fa1878e624dd33d68f296d31bd107a09eb8feb5ae7bd85b9c7f2e6bbf1ff1c296ae1fd938a828160c7c5a488
7
- data.tar.gz: 34688115cd7554ed4f660ab131dfcccb4dd7d15e24f72c7e32f6802d1a2c422d80cfea3cef47f18afc689eb653fc36102b5726d45129202f157d91f1d1db09b7
6
+ metadata.gz: 8e4a8bbbfa0398b816a6dd266a6b127f507e6329feaf2b5f37283f2eef76344a334e2bf60409978fab45ab714c56ca1c9c44754174222238d930845bc0f5d929
7
+ data.tar.gz: 139f5254bb1dbeec07cbdf6cb0033cdb34ce5eae967585a9ab9a843fb961528e0d16b77d562668789646b38b16eaca42e28f2338d49f4ebe47b42a58be6f9597
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ 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
+
10
41
  ## [0.145.0] - 2026-11-02
11
42
 
12
43
  ### Changed
@@ -5549,7 +5580,8 @@ and long-lived sessions.
5549
5580
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5550
5581
  hotkey panel that send selected text or the clipboard to your sessions.
5551
5582
 
5552
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.145.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
5553
5585
  [0.145.0]: https://github.com/dm1try/samagotchi/compare/v0.144.0...v0.145.0
5554
5586
  [0.144.0]: https://github.com/dm1try/samagotchi/compare/v0.143.0...v0.144.0
5555
5587
  [0.143.0]: https://github.com/dm1try/samagotchi/compare/v0.142.0...v0.143.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
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.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."]
@@ -5,42 +5,59 @@ require_relative "cli/command"
5
5
  require_relative "cli/exit"
6
6
  require_relative "cli/flags"
7
7
  require_relative "memory_paths"
8
+ require_relative "system_prompt"
8
9
  require_relative "tools/memory"
9
10
  require_relative "memory_bundle/index_updater"
10
11
  require_relative "memory_bundle/provenance"
12
+ require_relative "memory_usage"
11
13
 
12
14
  module Samagotchi
13
15
  # `chi memory`: the user's side of the memories (docs/memory.md). ls lists
14
16
  # both scopes' entries from their index.md lines and files, as memory_read
15
17
  # and the prompt see them; rm removes one as memory_write remove: true does
16
18
  # (Tools::MemoryWrite.remove: the file and its model overlays into the
17
- # bundle trash, the index line dropped, a bundle's memory refused).
19
+ # bundle trash, the index line dropped, a bundle's memory refused); usage
20
+ # reports what the memories cost and what the saved sessions read
21
+ # (MemoryUsage, docs/memory.md "What the memories cost").
18
22
  class MemoryCommand
19
23
  include CLI::Command
20
24
 
21
25
  USAGE = <<~TEXT
22
- Usage: chi memory <ls|rm> [options]
26
+ Usage: chi memory <ls|rm|usage> [options]
23
27
  ls [--scope project|system] [--format text|json] the memories: both scopes (this folder's project
24
28
  first), or one; each with its size, date, the bundle it came from and its index description
25
29
  rm NAME [--scope project|system] remove a memory: its file and its model overlays (NAME.<key>.md)
26
30
  move to the bundle trash (chi bundle trash lists and empties it) and its index line is dropped.
27
31
  Without --scope NAME must be in one scope only. A bundle's memory is refused: chi bundle
28
32
  uninstall removes it. NAME.<key> removes one model overlay alone.
33
+ usage [--scope project|system] [--top N] [--format text|json] what the memories cost, counted from
34
+ the saved sessions: each scope's index tokens, the memories read most (by tokens), and the ones
35
+ no saved session read. --top is the rows per section (10, at most 1000); no sessions says so and exits 0.
29
36
  TEXT
30
37
 
31
- SUBCOMMANDS = %w[ls rm].freeze
38
+ SUBCOMMANDS = %w[ls rm usage].freeze
32
39
  SCOPES = Tools::VALID_SCOPES
33
40
  # The description column's width in ls's text form (json has it all).
34
41
  DESCRIPTION_WIDTH = 60
35
42
  # ls's size column, right-aligned.
36
43
  SIZE_COLUMN = 2
44
+ # usage's rows per section, when --top is not given.
45
+ DEFAULT_TOP = 10
46
+ # The most rows --top may ask for: a report of more is unreadable, and a
47
+ # huge number used to raise out of Array#first.
48
+ TOP_LIMIT = 1000
37
49
 
38
50
  FLAGS = {
39
51
  "ls" => CLI::Flags.new(help: HELP_WORDS) do |f|
40
52
  f.value "--scope"
41
53
  f.value "--format"
42
54
  end,
43
- "rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--scope" }
55
+ "rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--scope" },
56
+ "usage" => CLI::Flags.new(help: HELP_WORDS) do |f|
57
+ f.value "--scope"
58
+ f.value "--top"
59
+ f.value "--format"
60
+ end
44
61
  }.freeze
45
62
 
46
63
  # One memory of a scope. +date+ is the index line's (the file's mtime
@@ -94,7 +111,7 @@ module Samagotchi
94
111
  return [] unless File.directory?(dir)
95
112
 
96
113
  index_path = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
97
- index_text = File.file?(index_path) ? File.read(index_path, encoding: "UTF-8") : ""
114
+ index_text = Tools::MemoryRead.read_text(index_path)
98
115
  # name => its line; the first one when a name has two. A name rm
99
116
  # would refuse (a path: "../x") isn't listed.
100
117
  lines = {}
@@ -191,6 +208,118 @@ module Samagotchi
191
208
 
192
209
  def clip(text) = text.length > DESCRIPTION_WIDTH ? "#{text[0, DESCRIPTION_WIDTH - 1]}…" : text
193
210
 
211
+ def run_usage(options, args)
212
+ return usage_error("usage takes no NAME (unexpected #{args.first})") unless args.empty?
213
+
214
+ format = options[:format]
215
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
216
+ return usage_error("--top must be a positive integer") unless options[:top].nil? || options[:top].to_s.match?(/\A[1-9]\d*\z/)
217
+ return usage_error("--top must be at most #{TOP_LIMIT}") if options[:top].to_i > TOP_LIMIT
218
+
219
+ sessions, unreadable = MemoryUsage.load_sessions
220
+ report = MemoryUsage.new(
221
+ sessions: sessions, entries: self.class.entries(options[:scope]),
222
+ scopes: options[:scope] ? [options[:scope]] : SCOPES, unreadable: unreadable, root: Dir.pwd,
223
+ # config.yml's memories: plus the system memories every prompt carries
224
+ # (SystemPrompt::DEFAULT_SYSTEM_MEMORIES): both are in the prompt from
225
+ # the start, so no read is needed for them.
226
+ baseline: ConfigFile.preloaded_memories + SystemPrompt::DEFAULT_SYSTEM_MEMORIES
227
+ ).report(top: (options[:top] || DEFAULT_TOP).to_i)
228
+
229
+ if format == "json"
230
+ @stdout.puts(JSON.pretty_generate(report.to_h))
231
+ else
232
+ print_usage(report)
233
+ end
234
+ CLI::Exit::OK
235
+ end
236
+
237
+ def print_usage(report)
238
+ if report.sessions.zero?
239
+ why = []
240
+ why << "#{report.unreadable} #{report.unreadable == 1 ? "file" : "files"} that could not be read" if report.unreadable.positive?
241
+ why << "#{report.skipped} #{report.skipped == 1 ? "session" : "sessions"} not counted (test runs or no user message)" if report.skipped.positive?
242
+ @stdout.puts(why.empty? ? "no sessions to count: nothing saved in #{Session.default_state_dir}" : "no sessions to count: #{why.join(" and ")} in #{Session.default_state_dir}")
243
+ return
244
+ end
245
+
246
+ header = "#{report.sessions} #{report.sessions == 1 ? "session" : "sessions"} over " \
247
+ "#{report.days} #{report.days == 1 ? "day" : "days"}"
248
+ header += ", #{report.unreadable} unreadable #{report.unreadable == 1 ? "file" : "files"} skipped" if report.unreadable.positive?
249
+ header += ", #{report.skipped} #{report.skipped == 1 ? "session" : "sessions"} not counted (test run or no user message)" if report.skipped.positive?
250
+ @stdout.puts(header)
251
+ @stdout.puts
252
+
253
+ # The index comes first: it is paid on every prompt, reads only on a read.
254
+ print_table(["Indexes", "tokens", "limit", "index lines naming a memory no session read"],
255
+ report.indexes.map do |index|
256
+ [indent(index.scope), token_text(index.tokens), index.limit.positive? ? index.limit.to_s : "off",
257
+ "#{index.unread_lines} of #{index.lines} (#{index.unread_percent}%)"]
258
+ end, right: [1, 2])
259
+ @stdout.puts
260
+
261
+ if report.read_count.zero?
262
+ @stdout.puts("No memory reads in these sessions.")
263
+ elsif report.top.empty?
264
+ # Every read is of a name with no memory behind it: no rows to show.
265
+ @stdout.puts("Most read, by tokens: none of these reads names a memory still there " \
266
+ "(#{report.unknown_names} #{report.unknown_names == 1 ? "name" : "names"} read, " \
267
+ "#{token_text(report.read_tokens)} tokens)")
268
+ else
269
+ share = report.read_tokens.positive? ? (100.0 * report.top.sum(&:tokens) / report.read_tokens).round : 0
270
+ @stdout.puts("Most read, by tokens (these #{report.top.size} of #{report.read_names}: " \
271
+ "#{share}% of #{token_text(report.read_tokens)} read tokens)")
272
+ print_table(%w[Memory tokens reads size scope from],
273
+ report.top.map do |use|
274
+ [indent(use.name), token_text(use.tokens), use.reads.to_s,
275
+ size_text(use.size_tokens), use.scope, use.source.to_s]
276
+ end, right: [1, 2])
277
+ if report.cut_reads.positive?
278
+ @stdout.puts(indent("note: #{report.cut_reads} of these reads came back cut (reads used to be cut), " \
279
+ "so a big memory may cost more per read now than these numbers show"))
280
+ end
281
+ end
282
+ @stdout.puts
283
+
284
+ if report.not_read_total.zero?
285
+ @stdout.puts("Not read in saved sessions: nothing of your own to count (a bundle's memories are not listed)")
286
+ else
287
+ @stdout.puts("Not read in saved sessions: #{report.not_read_count} of #{report.not_read_total} of your own " \
288
+ "memories (a bundle's are not listed)")
289
+ print_table(["", "size", "scope"], report.not_read.map { |entry| [indent(entry.name), size_text(entry.tokens), entry.scope] }, right: [1]) if report.not_read.any?
290
+ @stdout.puts(indent("An index line is paid whatever a session reads: a memory nobody opens still costs its line in every prompt."))
291
+ end
292
+
293
+ @stdout.puts(indent("Names read that have no memory now: #{report.unknown_names}")) if report.unknown_names.positive?
294
+ end
295
+
296
+ def indent(text) = " #{text}"
297
+
298
+ # A token count as usage's columns show it: "450", "1.9k", "2.0M".
299
+ def token_text(value)
300
+ number = value.to_i
301
+ return number.to_s if number < 1000
302
+ return format("%.1fk", number / 1000.0) if number < 1_000_000
303
+
304
+ format("%.1fM", number / 1_000_000.0)
305
+ end
306
+
307
+ # A size column: "-" when the memory has no file to measure.
308
+ def size_text(tokens) = tokens.nil? ? "-" : token_text(tokens)
309
+
310
+ # A header row and its rows, two spaces apart; the +right+ columns align on
311
+ # their numbers.
312
+ def print_table(header, rows, right: [])
313
+ table = [header, *rows]
314
+ widths = table.transpose.map { |column| column.map { |cell| cell.to_s.length }.max }
315
+ table.each do |cells|
316
+ line = cells.each_with_index.map do |cell, index|
317
+ right.include?(index) ? cell.to_s.rjust(widths[index]) : cell.to_s.ljust(widths[index])
318
+ end
319
+ @stdout.puts(line.reject.with_index { |_, index| widths[index].zero? }.join(" ").rstrip)
320
+ end
321
+ end
322
+
194
323
  def run_rm(options, args)
195
324
  name = args.shift
196
325
  return usage_error("give the memory's NAME") unless name
@@ -229,9 +358,8 @@ module Samagotchi
229
358
 
230
359
  def present?(name, scope)
231
360
  dir = MemoryPaths.scope_dir(scope)
232
- index = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
233
361
  File.file?(File.join(dir, "#{name}.md")) ||
234
- (File.file?(index) && File.read(index, encoding: "UTF-8").match?(Tools::MemoryWrite.managed_pattern(name)))
362
+ Tools::MemoryRead.read_text(File.join(dir, "#{Tools::MEMORY_INDEX}.md")).match?(Tools::MemoryWrite.managed_pattern(name))
235
363
  end
236
364
  end
237
365
  end
@@ -51,6 +51,15 @@ module Samagotchi
51
51
  File.basename(common) == ".git" ? File.dirname(common) : common
52
52
  end
53
53
 
54
+ # The repository's common git dir of +cwd+ as an absolute real path (the
55
+ # same for every worktree of it), or nil outside any repository. Reads
56
+ # files only, no git process; #project_root turns it into a checkout
57
+ # folder, which a bare layout's container is not.
58
+ def common_git_dir(cwd = Dir.pwd)
59
+ gitdir = git_dir(cwd)
60
+ gitdir && File.realpath(common_dir(gitdir))
61
+ end
62
+
54
63
  # The top of the git work tree holding +cwd+: the directory with the
55
64
  # nearest .git (a linked worktree's own checkout, unlike #project_root);
56
65
  # nil outside any repository.