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 +4 -4
- data/CHANGELOG.md +33 -1
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +7 -2
- data/docs/internals/bridge.md +3 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/web-frontend.md +3 -2
- data/docs/memory.md +45 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +13 -6
- data/lib/samagotchi/guardrails/approval.rb +17 -4
- data/lib/samagotchi/guardrails/parent_approvals.rb +5 -1
- data/lib/samagotchi/guardrails/verdict.rb +5 -3
- data/lib/samagotchi/memory_command.rb +135 -7
- data/lib/samagotchi/memory_paths.rb +9 -0
- data/lib/samagotchi/memory_usage.rb +368 -0
- data/lib/samagotchi/relay.rb +2 -2
- data/lib/samagotchi/tool_activity.rb +3 -3
- data/lib/samagotchi/tool_output_cut.rb +12 -1
- data/lib/samagotchi/toolkit_suggest.rb +7 -4
- data/lib/samagotchi/tools/delegate.rb +88 -13
- data/lib/samagotchi/tools/memory.rb +12 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/app.js +6 -2
- data/lib/samagotchi/web/public/participant_annotate.js +2 -1
- data/lib/samagotchi/web/public/question_cards.js +33 -9
- data/lib/samagotchi/web/public/revise_stack.js +6 -5
- data/lib/samagotchi/web/public/verify_preset.js +5 -1
- data/lib/samagotchi/web/public/worker_features.js +14 -0
- data/lib/samagotchi/working_set.rb +3 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 701bfb63903962a149abf5d17f39be6c043141e5e1b9ea952e7ba27b77ebe3d8
|
|
4
|
+
data.tar.gz: 7be23545775dc31bba230e8dfde6c896def7530d032cc8ae7f79d2a9b91d7782
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
data/docs/internals/bridge.md
CHANGED
|
@@ -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.
|
|
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);
|
data/docs/internals/engine.md
CHANGED
|
@@ -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 `
|
|
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
|
|
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`.
|
|
@@ -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:
|
|
316
|
-
#
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
193
|
-
verdict.settle!(:deny, decided_by:
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|