samagotchi 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/docs/architecture.md +9 -1
  4. data/docs/cli.md +9 -1
  5. data/docs/configuration.md +4 -0
  6. data/docs/guardrails.md +26 -3
  7. data/docs/plugins.md +9 -3
  8. data/docs/sessions.md +9 -1
  9. data/docs/sub-agent.md +19 -8
  10. data/docs/testing.md +19 -0
  11. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +1 -1
  13. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  14. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/cli/parent_wait.rb +9 -3
  19. data/lib/samagotchi/engine.rb +31 -5
  20. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  21. data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
  22. data/lib/samagotchi/guardrails/rules.rb +4 -2
  23. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  24. data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
  25. data/lib/samagotchi/guardrails/targets.rb +14 -0
  26. data/lib/samagotchi/host_config.rb +8 -8
  27. data/lib/samagotchi/idle_client.rb +36 -11
  28. data/lib/samagotchi/idle_recap.rb +6 -1
  29. data/lib/samagotchi/idle_target.rb +12 -4
  30. data/lib/samagotchi/llm/http.rb +7 -2
  31. data/lib/samagotchi/llm/usage.rb +9 -3
  32. data/lib/samagotchi/model_price.rb +16 -0
  33. data/lib/samagotchi/parent_report.rb +7 -3
  34. data/lib/samagotchi/plugin/context.rb +10 -3
  35. data/lib/samagotchi/plugin/service.rb +24 -1
  36. data/lib/samagotchi/recap_setup.rb +2 -1
  37. data/lib/samagotchi/reply_wait.rb +68 -2
  38. data/lib/samagotchi/send_command.rb +47 -8
  39. data/lib/samagotchi/session_metrics/side_totals.rb +117 -0
  40. data/lib/samagotchi/session_metrics.rb +49 -12
  41. data/lib/samagotchi/session_observer.rb +3 -0
  42. data/lib/samagotchi/side_usage.rb +38 -0
  43. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  44. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  45. data/lib/samagotchi/testing/boundary.rb +15 -0
  46. data/lib/samagotchi/testing/bundle.rb +35 -9
  47. data/lib/samagotchi/testing/host.rb +2 -2
  48. data/lib/samagotchi/testing/recorder.rb +7 -4
  49. data/lib/samagotchi/testing/sandbox.rb +5 -3
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +4 -1
  52. data/lib/samagotchi/web/public/app.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +32 -6
  54. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  55. data/lib/samagotchi/web/public/lists_view.js +1 -1
  56. data/lib/samagotchi/web/public/question_cards.js +10 -5
  57. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  58. data/lib/samagotchi/web/public/timing.js +6 -1
  59. data/lib/samagotchi/web/session_summary.rb +2 -0
  60. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e8f390119025dcb6df095574fb9e59e94952c709a69ae6bc6e02ffa037e1a0d
4
- data.tar.gz: c67e91b18d0ef7ac8916b03045c214c12ebcf98b469f84b0ae355bb41491388e
3
+ metadata.gz: 02be0edba759be799428a67ac174beba8a77c2869f3de8f677dec9a14977ae60
4
+ data.tar.gz: d28e0688aec5428264fc7a48162a7f4e03ea461371cc48aba4da3c64ecf2e9d8
5
5
  SHA512:
6
- metadata.gz: 4c936b332ee7cafb74cb4e09b7f3b80acac7ba1247988e755078c61e72c201489b1a48f8970c6c3d70d88b7cedc31cf432d01c5b14018073a517b65f76e45de6
7
- data.tar.gz: dc77457ac7bf6a0018966bef40ccaa67da732a43edbe21e0fbcef1193583a3e00934c70c4230d3fcf14f5dd23012730f9c41bb16be8d7a64754f361ff3c2ab4a
6
+ metadata.gz: 1df47ccd973923c62848fef02807f3982ede1b86ebf032425c1ecd311014103286803822a290b24f117d705e3e8f845b22cb4804e90cc731a8dd48a3946a3b6f
7
+ data.tar.gz: 3c901380929266d60c820ebba9ad3581bdb4392f498fbb43dcef6ba157e675c100a8dba752ee27a79d464692ffb0a407b75ead89961f38c89e6551bf8a9a6ada
data/CHANGELOG.md CHANGED
@@ -8,6 +8,60 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.82.0] - 2026-10-18
12
+
13
+ ### Added
14
+
15
+ - `chi send --wait --format json` names the turn it waited for (`turn_id`), and `chi send --wait --turn T ID` waits
16
+ for exactly that turn: running, queued or the last one that ended. A parent waiting again after exit 3 or 4 uses it
17
+ (docs/sub-agent.md).
18
+ - Bundles: the experimental test kit's `before_tool_call_event` builds the real guardrail event without firing
19
+ bundle hooks.
20
+
21
+ ### Fixed
22
+
23
+ - `chi send --wait ID` with no message, on an idle session whose last turn already ended, returns that turn (its
24
+ reply, or its end when it left none) instead of timing out `running`.
25
+ - Web: a question or approval card arriving in the stage no longer snaps the stage to it; the stage eases it into
26
+ view.
27
+ - Guardrails: `rm` and protected-path checks treat a `$(cat <<'EOF' … EOF)` commit or PR message as data; a
28
+ substitution that runs, a piped message and a real command beside it are still checked.
29
+ - A late side request (an idle recap, `ctx.ask_model`) after the engine moved to another session no longer recreates
30
+ the deleted old session's folder.
31
+ - HTTP: failed-request messages in the log are one short UTF-8 line.
32
+ - Bundles, test kit: one `HookFailed` names every hook that failed; the boundary scan catches a `require_relative`
33
+ into chi's `lib/`; `before_tool_call_event` says when the guardrails didn't dispatch the event.
34
+
35
+ Update with `chi update` (the system bundle 0.82.0), then restart `chi web`.
36
+
37
+ ## [0.81.0] - 2026-10-18
38
+
39
+ ### Added
40
+
41
+ - Stats: side requests (the idle recap, `/btw` and plugins' `ctx.ask_model`) are counted on their own
42
+ `side requests:` line in `/stats` and the web's ctx tooltip, with their tokens and cost; the session's totals stay
43
+ its turns'.
44
+
45
+ ### Fixed
46
+
47
+ - Guardrails: a `command:` rule no longer matches the text of a heredoc its command only reads as data
48
+ (`git commit -F -`, `gh pr create --body-file -`, `cat` to stdout, a `"$(cat <<'EOF' …)"` commit message); a
49
+ heredoc fed to a shell or an interpreter, or piped on (also from a `( … )` or `{ … }` group), is still
50
+ matched. The bundle's `chi-answer-piped` (guardrails bundle 0.9.1) no longer asks about stdin from
51
+ `/dev/null`.
52
+ - Guardrails: the `rm` and `touches` checks now see a heredoc that bash runs after all — piped on from a `( … )` or
53
+ `{ … }` group, fed through `<( … )` / `>( … )`, or written with `&>` / `>& file` and then run — which they used to
54
+ read as plain data.
55
+ - Plugins: a `chi.service` that starts lazily from a tool or hook handler runs its start block outside the handler,
56
+ so Stop no longer cancels the service's startup `ctx.http` calls (as docs/plugins.md says).
57
+ - Bundles: the test kit's sandbox also points `XDG_DATA_HOME` and `XDG_CACHE_HOME` at its temp folder, so a bundle
58
+ test no longer writes to your own data or cache home when you set them.
59
+ - `chi bootstrap`: re-running it against a server the config already has says so, without the misleading "saved as …"
60
+ line before it.
61
+
62
+ Update with `chi update` (the system bundle 0.81.0, `guardrails` 0.9.1, `slow-commands` 0.2.3), then restart
63
+ `chi web`.
64
+
11
65
  ## [0.80.0] - 2026-10-17
12
66
 
13
67
  ### Added
@@ -3310,7 +3364,9 @@ and long-lived sessions.
3310
3364
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3311
3365
  hotkey panel that send selected text or the clipboard to your sessions.
3312
3366
 
3313
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.80.0...HEAD
3367
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.82.0...HEAD
3368
+ [0.82.0]: https://github.com/dm1try/samagotchi/compare/v0.81.0...v0.82.0
3369
+ [0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
3314
3370
  [0.80.0]: https://github.com/dm1try/samagotchi/compare/v0.79.0...v0.80.0
3315
3371
  [0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
3316
3372
  [0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
data/docs/architecture.md CHANGED
@@ -338,6 +338,14 @@ carries the generation's `speed` (`{decode_tps, source}`, null without one), its
338
338
  counts (`prompt_tokens`, `cached_tokens`, `cache_write_tokens`: null when the server reported no writes) and
339
339
  those running `tokens` and `model_segments`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
340
340
  it, so the web's info bar updates per generation without parsing chunk payloads.
341
+ Side requests (the idle recap, a plugin's `ctx.ask_model` such as `/btw`) are counted apart, in a `side` block
342
+ (only when there were any; `SessionMetrics::SideTotals`: requests, token sums, `cost_sum` null until reported,
343
+ `cost_estimate_sum` from the target host's price, `by_kind`): `IdleClient` hands each reply's usage to its
344
+ `on_usage:` callback, `Engine#note_side_usage`, which folds it (`SessionMetrics#add_side`) and announces
345
+ `:side_usage` (`kind`, the whole `side` block) under the event lock, so the blocks arrive in fold order, then saves
346
+ `analytics.json` between turns (a running turn's end saves it). `tokens`, `model_segments` and the turn records
347
+ stay the turns' only. `SessionMetrics#persist` is serialized (its own mutex, taken before the metrics one), so two
348
+ threads' saves land in snapshot order.
341
349
 
342
350
  The page's frequent reads of `GET /api/sessions/:id` stay light too:
343
351
  - `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
@@ -523,7 +531,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
523
531
  | Attached context | `context_sources.rb` (`ContextSources`), `context_poller.rb`, `context_absorber.rb`, `peer_sources.rb` (`PeerSources`: the sources peer bundles declare) | External sources a session keeps fresh, and the peer loop's wakes ([context.md](context.md)). |
524
532
  | LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
525
533
  | Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
526
- | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows). |
534
+ | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows). `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
527
535
  | Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
528
536
  | Toolkits | `toolkit.rb` (`Toolkit`), `toolkit_binding.rb` (`ToolkitBinding`, the Engine's seam), `toolkit_command.rb` (`/toolkit`), `tools/registry.rb` (`Registry#toolkit`, `#left_out_names`), `web/public/toolkit_chip.js` | Which registered tools a session's prompts declare: saved in the session, resolved at a turn's start (`Engine#apply_toolkit!`), named in the prompt's not-loaded line, a kit's prompt text after the appended files; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
529
537
  | Tool index | `tools/tool_index.rb` (`Tools::ToolIndex`) | `find_tools` and `call_tool`: the tools a session knows but doesn't declare one by one (an mcp bundle's servers through `chi.tool_source`, the built-ins its toolkit leaves out), searched and called by name ([plugins.md](plugins.md)). |
data/docs/cli.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`, a `--days`, `--keep` or `--limit` that isn't a whole number) exits 2; an unknown or refused session exits 1
19
19
  - `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
20
20
  - `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
21
- - `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; `--format json` prints one JSON object instead; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
21
+ - `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending — to an idle session whose last turn already ended, that ended turn's reply or end comes back; `--wait --turn T ID` with no message waits for exactly turn T, whether running, queued or last ended; `--format json` includes its `turn_id` when known, for waiting again after exit 3 or 4; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
22
22
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). An approval can be denied, not allowed, unless `guardrails.parent_approvals: once` (a convention, not a security boundary; see [Guardrails](guardrails.md#approvals-from-a-parent-agent))
23
23
  - `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))
24
24
  - `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))
@@ -486,6 +486,14 @@ Generations under 8 tokens or 100 ms get no speed of their own, nor does a
486
486
  reply without server token counts or a non-streamed one. A delegated child
487
487
  keeps its own numbers: a parent's tokens and cost leave them out.
488
488
 
489
+ Side requests have their own line, right after `cost:` (in its place without one), only when the session made any:
490
+ `side requests: 3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009 (not in the totals above)`. It
491
+ counts the idle recap (`recap`), `/btw` and every other plugin's `ctx.ask_model` (under the asking bundle's name),
492
+ with their tokens and cost: a reported cost as is, else estimated from the price of the model they asked, on its
493
+ host (as a turn's, `~`). They are never in the tokens, `models:` or `cost:` lines above it, which stay the turns'.
494
+ A recap thrown away (stale, overdue) still counts: it was billed. `chi sessions stats` shows the line too (saved
495
+ between turns), and the web's ctx tooltip has the same words.
496
+
489
497
  After a `/model` switch (or a hook's) the tokens line becomes a `models:` block, a row per run of turns on one
490
498
  model, in order (A → B → A is three rows): the model (`host:model` as asked), when (`10:43–now` while it runs; with
491
499
  the day when the rows span several), turns, tokens in/out, its average speed and cost. Under the row of the model
@@ -573,6 +573,10 @@ hosts:
573
573
  - The estimate is saved apart from the reported cost (`cost_estimate` per turn, `cost_estimate_sum` in the
574
574
  session's totals in `analytics.json`) and shows with a `~`.
575
575
  - A server that sends no cached counts has its whole prompt priced at `input`: an overestimate.
576
+ - Side requests (the idle recap, `/btw`, a plugin's `ctx.ask_model`) are priced by the same rule from the price of
577
+ the model they asked on its host: the recap's host and model (`recap.*`, else the session's), `ask_model(model:)`'s
578
+ host. A recap using only `recap.base_url` has no host price. They count on `/stats`' own `side requests:` line,
579
+ never in the session's `cost:`.
576
580
 
577
581
  ## Thinking
578
582
 
data/docs/guardrails.md CHANGED
@@ -230,7 +230,7 @@ guardrails:
230
230
  rules:
231
231
  - id: git-push
232
232
  tool: shell # execute + task_create; or a tool name, a glob, or a list
233
- command: '\bgit\s+push\b' # Ruby regex on the command
233
+ command: '\bgit\s+push\b' # Ruby regex on the command (data heredoc bodies left out)
234
234
  verdict: ask # ask | deny
235
235
  reason: git push publishes commits
236
236
  scopes: [once, session, repo] # optional; default all four
@@ -248,6 +248,23 @@ guardrails:
248
248
 
249
249
  A rule gives at least one of `tool`, `command`, `path`, `git`, `touches`, `rm` and `memory`.
250
250
 
251
+ `command:` reads the command without the body of a heredoc its command
252
+ only reads as data: `cat` or `tee` to stdout, `git commit`/`git tag` with
253
+ `-F -`/`--file -`, `gh pr|issue`/`gh release` with `--body-file -`/
254
+ `--notes-file -`, and a `"$(cat <<'EOF' … EOF)"` given to `-m`, `--body`
255
+ or another message option. Each counts only when its command ends at a
256
+ newline, `;`, `&&` or `||` and sits outside any `( … )` or `{ … }` group;
257
+ the `<<TAG` word and the terminator line stay in the text, so a rule on
258
+ `<<` or on what follows the terminator still matches. A heredoc fed to
259
+ anything else (`bash`, an interpreter, `ssh`, a wrapper), an unquoted one
260
+ whose body holds `$(…)` or backticks, a piped one, a file-writing
261
+ `cat`/`tee` (a `>&2` or `>&-` writes no file; `>& file` does), a
262
+ here-string and an unterminated heredoc all stay in it. An fd opened
263
+ earlier and written to by number (`exec 3>r.sh; cat <<E >&3`) isn't
264
+ followed, so that body counts as data: out of scope here, which is
265
+ chi-nah's job for a model that goes looking. Approvals, the approval
266
+ question and hooks see the whole command.
267
+
251
268
  `memory: remove` matches a `memory_write` with `remove: true` (it moves a
252
269
  memory into the bundle trash). Nothing else asks about it: a memory's file is
253
270
  never outside the repo (below), so without such a rule any session, a delegate
@@ -493,7 +510,7 @@ only by the user: these change a model's system prompt for later sessions (see
493
510
  [Model notes](memory.md#model-notes); a shell command reaches them only under
494
511
  `shell-touches-chi`'s text heuristic); and answering chi's
495
512
  questions around `chi answer`: `chi --attach` or `chi -p` with stdin from a
496
- pipe, a here-string or a file (`chi-answer-piped`), and `curl`/`wget` to a
513
+ pipe, a here-string or a file, not `/dev/null` (`chi-answer-piped`), and `curl`/`wget` to a
497
514
  session's `/answer` route (`chi-answer-http`), both once or for the session.
498
515
  `chi answer` itself isn't asked about. It denies writes into `.git/hooks`.
499
516
 
@@ -591,7 +608,13 @@ else goes to chi's own rules. It needs nah installed; see its README for the set
591
608
 
592
609
  Text matching on shell commands stops accidents, not a model set on getting
593
610
  around it: `sh -c`, base64, a script written earlier, `git -C` variants and
594
- aliases can get past a regex. `outside_repo` covers the file tools and git
611
+ aliases can get past a regex, and these rules are built for trusted models (a
612
+ model set on getting around them is chi-nah's job). A heredoc counts as data
613
+ only for the consumers listed under [Rules in config.yml](#rules-in-configyml)
614
+ (`cat`/`tee` to stdout, `git commit`/`git tag` `-F -`, `gh`'s body and notes
615
+ files, a `"$(cat <<'EOF' … EOF)"` message): anything else keeps its body in
616
+ the text a rule sees, and a function or alias shadowing a reader in the same
617
+ command isn't seen at all. `outside_repo` covers the file tools and git
595
618
  only: `sed -i`, `cp` or `cat > file` into another folder from the shell aren't
596
619
  asked about, and neither is git run through `sh -c`, a script, an alias, `make`
597
620
  or a folder in a variable. `!cmd` lines typed by you are not checked. The only
data/docs/plugins.md CHANGED
@@ -858,6 +858,10 @@ next turn never see it, and no hook fires. It returns the answer text.
858
858
  It goes through the host's OpenAI API (`/v1/chat/completions`), which
859
859
  llama.cpp serves on a native host too.
860
860
 
861
+ Each call counts in the session's side requests, under the bundle's name: `/stats`' `side requests:` line and the
862
+ web's ctx tooltip (`2 (btw 1, recap 1) · …`), with its tokens and its cost (reported, else estimated from the target
863
+ model's price), never in the session's own totals.
864
+
861
865
  ## Calling services: `ctx.http`
862
866
 
863
867
  ```ruby
@@ -923,7 +927,8 @@ Every call also takes `cancel:`, a `Samagotchi::CancellationController`, which r
923
927
  - **Stop.** A call made for the turn (a tool, a hook's handler) stops when the turn is stopped, and a call inside an
924
928
  init task when chi shuts down; a call from your own thread, a `chi.service` or an anytime command has only its
925
929
  timeout unless you pass `cancel:`. After the turn has ended (an `:after_turn` or `:session_end` hook of a
926
- stopped turn) a call is not cancelled by that stop.
930
+ stopped turn) a call is not cancelled by that stop. A service's start block counts as the service's even when its
931
+ first `value` comes from a handler.
927
932
  - **The log.** Each call writes one `http` line to the debug log: INFO (ERROR when it fails) with `bundle=`,
928
933
  `purpose=plugin`, the method, the URL **without its query**, the status and the time. Never headers or bodies.
929
934
  A plugin that polls a dead service therefore logs an ERROR per poll.
@@ -1129,6 +1134,7 @@ require "samagotchi/testing"
1129
1134
  bundle = Samagotchi::Testing::Bundle.load("path/to/my-bundle", settings: { "every" => 3 },
1130
1135
  host: { session_id: "s1", cwd: Dir.pwd })
1131
1136
  bundle.fire(:after_turn, status: "completed") # → the event, after the hooks
1137
+ event = bundle.before_tool_call_event({ name: "read", path: "a.txt" }, params: "read a.txt")
1132
1138
  bundle.command("/mine stop") # → what the command returned
1133
1139
  bundle.tool("my_tool", path: "a.txt") # → the text the model reads
1134
1140
  verdict = bundle.tool_call({ name: "execute", content: "rm -rf /" })
@@ -1164,8 +1170,8 @@ bundle.shutdown
1164
1170
  `bundle.set(cancelled: true)` makes a call without `cancel:` raise `HttpCancelled`.
1165
1171
  - **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
1166
1172
  go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
1167
- - **Sandbox.** A test runs with temp config and state homes (config.yml, memories, `ctx.data_dir`, the debug
1168
- log), `HOME`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, no git config of yours, no Keychain and none of your
1173
+ - **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
1174
+ debug log), `HOME`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, no git config of yours, no Keychain and none of your
1169
1175
  `SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml.
1170
1176
  - **RSpec:** `require "samagotchi/testing/rspec"`; example groups tagged `:bundle` (a spec under a bundle's own
1171
1177
  `spec/` is tagged by itself) get `load_bundle(dir)`, `shipped_bundle(name)` and `sandbox`, each example in its
data/docs/sessions.md CHANGED
@@ -51,6 +51,14 @@ chi sessions stats ID [--format text|json] # cost, tokens and progres
51
51
 
52
52
  `chi sessions stats ID` prints what `/stats` prints in a terminal, headed by `<id8> <status> <model>`: a live worker answers its Bridge's `GET stats` (no model call, and the command never starts a worker); without one, or when the worker does not answer in time, the same snapshot is rebuilt from the session's saved `analytics.json` (and the line says so when a worker was live). `--format json` prints one object `{session_id, status, live, metrics}` with the snapshot as `metrics` (its `turn_records` / `tool_records` are the recent ones only: a live worker's newest finished turn and any not yet saved, the disk snapshot's newest saved turn; `analytics.json` holds them all); a session with no `analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and still exits 0. An unknown or ambiguous id exits 1 with the same wording `chi sessions stop` uses.
53
53
 
54
+ `analytics.json` also has a `side` block when the session made side requests (the idle recap, `/btw` and other
55
+ plugins' `ctx.ask_model`): `requests`, `prompt_sum`, `completion_sum`, `cached_sum`, `cache_write_sum`,
56
+ `reasoning_sum`, `cost_sum` (the reported cost; null until one was), `cost_estimate_sum` (from the target host's
57
+ price; 0 without one) and `by_kind` (`{recap: {requests, cost_sum, cost_estimate_sum}, btw: {…}}`, in first-seen
58
+ order: `recap`, else the asking bundle's name). It has no records: the block itself is saved (at once between turns,
59
+ by the turn's end during one) and loaded; it is never in `tokens` or `model_segments`. `chi sessions stats --format
60
+ json` has it in `metrics.side`; a file without it (older, or no side requests) has none.
61
+
54
62
  Each turn record in `analytics.json` names the model the turn ran on as `model_ref` (the asked `host:model`, from its first generation; `model` stays the name the server says it served); a turn stopped before it asked a model has none. The first turn after a `/model` switch (or a hook's) to another model also records what its first generation prefilled on the new model: `switch_prefill_tokens` (the prompt less the cached reads; none under 128 tokens, or for a generation with estimated counts) and `switch_prefill_cost_estimate` (from the new model's configured price, no completion; none without a price). A session saved before this has no `model_ref` on its records.
55
63
 
56
64
  A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
@@ -200,7 +208,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
200
208
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would merge into it, and its reply would come back as the answer. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
201
209
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
202
210
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
203
- - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs. With neither (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
211
+ - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
204
212
  - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level and toolkit, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool` and `--no-tool` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
205
213
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
206
214
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
data/docs/sub-agent.md CHANGED
@@ -30,6 +30,7 @@ sources](context.md#peer-sources-the-peer-loop).
30
30
  ```sh
31
31
  chi send --new --wait --format json [--dir D] [--model M] [--timeout S] -m "task"
32
32
  chi send --wait --format json [--timeout S] [-m "follow-up"] ID
33
+ chi send --wait --format json [--timeout S] --turn T ID
33
34
  chi send --queue --wait --format json [--timeout S] -m "follow-up" ID
34
35
  chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
35
36
  chi answer ID --question QID --dismiss [--timeout S] [--format json]
@@ -60,6 +61,11 @@ Exit codes, both commands:
60
61
 
61
62
  With `--format json` stdout is one JSON object (see
62
63
  [Starting a session](sessions.md#starting-a-session) for every field):
64
+ when the wait is about a known turn, it includes `turn_id`. Use
65
+ `chi send --wait --format json --turn <turn_id> <session_id>` to wait for
66
+ that exact turn again after exit 4, or after exit 3 if someone else will
67
+ answer the question. A turn id can name the running turn, a queued turn or
68
+ the last turn that ended; any other id is an error.
63
69
 
64
70
  ```json
65
71
  {"status":"question","session_id":"297da360-…","question":{"id":"3f1c…","kind":"question",
@@ -115,7 +121,7 @@ a parent may deny it (`--option Deny --text "why"`, `--text` alone, or
115
121
  and tell your user`. For an approval, the question block and `answer_with` give
116
122
  the deny command, not `--option N` or `chi --attach`. The block also offers to
117
123
  leave it open: tell your user it waits in chi web (the bell and badge show
118
- it there), and `chi send --wait --format json ID` (no message) waits until
124
+ it there), and `chi send --wait --format json --turn <turn_id> ID` waits until
119
125
  they answer it. With
120
126
  `guardrails.parent_approvals: once` in chi's config.yml (it has no environment
121
127
  variable), "Allow once" goes through; the wider scopes never do,
@@ -161,7 +167,11 @@ answers from any local process.
161
167
  - `chi send --wait ID` with no message waits past a question already reported
162
168
  (the step-limit question too), until someone answers it. It waits for the
163
169
  turn running when it starts (or the one the step-limit question continues),
164
- and prints that turn's reply, not a queued one's after it.
170
+ and prints that turn's reply, not a queued one's after it. To an idle
171
+ session whose last turn already ended (no question standing), that ended
172
+ turn is the answer: its reply comes back (`answered`), or its end when it
173
+ left no reply (`failed`, `canceled`, `limit`, …) — so a turn that finished
174
+ in the gap between waits is reported, not waited for.
165
175
  - An answer to a question that is no longer open (you answered it in the web
166
176
  first) isn't sent; `chi answer` says so and waits for what comes next. Its
167
177
  JSON then has `"answered_here": false` (absent when the answer went in), so
@@ -185,18 +195,19 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
185
195
  user, showing them the question and its options; if you can't ask (no user in
186
196
  this run), stop and report the question and options instead of guessing. To
187
197
  answer, run the `answer_with` command with `--option N` (1-based, repeat for
188
- multi-select) and/or `--text "…"`. It returns the same JSON. If you won't
189
- answer, add `--dismiss` instead: chi does nothing it asked about, finishes
190
- with what it found, and the reply comes back (exit 0).
198
+ multi-select) and/or `--text "…"`. It returns the same JSON. If you leave it
199
+ open for someone else to answer, wait again with `--turn <turn_id>` from the
200
+ JSON. If you won't answer, add `--dismiss` instead: chi does nothing it asked
201
+ about, finishes with what it found, and the reply comes back (exit 0).
191
202
  - `question.kind: approval`: chi wants to run something its user's guardrails
192
203
  flagged, and allowing it is up to your user. Deny it (the `answer_with`
193
204
  command: `--option Deny --text "why"`), and tell your user what it wanted to
194
205
  run and why. Or leave it open: tell your user it waits in chi web (the
195
- session's short id), then `chi send --wait --format json --timeout 500 <session_id>`
206
+ session's short id), then `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
196
207
  waits until they answer it there. Never allow it yourself, through
197
208
  `chi answer`, a piped `chi --attach` or chi's web API.
198
- - `status: running` (exit 4): still working. Wait again:
199
- `chi send --wait --format json --timeout 500 <session_id>`.
209
+ - `status: running` (exit 4): still working. Wait for that same turn again:
210
+ `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`.
200
211
  - `question.kind: continue`: chi ran out of steps before it answered. If it is
201
212
  getting somewhere, continue it (`answer_with`, `--option Continue`, with
202
213
  `--text "…"` to steer it on the way); if not,
data/docs/testing.md CHANGED
@@ -83,6 +83,25 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
83
83
  bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
84
84
  no longer used. Fix the reference (the kit or a stable API), or add the exception with its why.
85
85
 
86
+ ## Guardrail cases corpus
87
+
88
+ `spec/fixtures/guardrails/cases.jsonl` holds one labelled tool call per line, run by
89
+ `spec/guardrails/cases_corpus_spec.rb` against a fixed rule set (`spec/fixtures/guardrails/rules.yml`) or the
90
+ shipped guardrails bundle's rules. A case:
91
+
92
+ - `id` (required, unique, kebab-case), `tool` (required: `execute`, `task_create`, `write`, `edit` or `read`),
93
+ `command` (a shell tool) or `path` (a file tool), `cwd` (optional: the call's `cwd:`, relative to the spec's
94
+ temp repo), `expect` (required), `rule` (a rule id), `rules` (optional: `fixture`, the default, or
95
+ `guardrails-bundle`), `source` (required: where the case came from: `heredoc-fp`, `heredoc-bypass`,
96
+ `chi-answer-piped`) and `note` (optional: why the label is what it is).
97
+ - `expect` is one of two kinds: `match` / `no_match` say whether rule `rule` matches the call (`Rule#matches?`),
98
+ and `allow` / `ask` / `deny` the whole rule set's decision (`Rules#check`, not the Gate's core checks); with
99
+ `rule` given, an ask or deny must also be decided by that rule.
100
+
101
+ Add a case with every guardrail false positive or miss you fix, under the `source` it came from. Never relabel
102
+ a case to make a spec pass: a case that goes red means the code is wrong. The spec also keeps the corpus
103
+ well formed (unique ids, known keys and labels, a rule that exists in its set).
104
+
86
105
  ## While you work: affected specs
87
106
 
88
107
  ```sh
@@ -280,7 +280,7 @@ module Samagotchi
280
280
  # neither url: nor host: is Anthropic's own API (HostConfig's rule).
281
281
  def location(fields)
282
282
  url = fields["url"].to_s.strip
283
- url = ANTHROPIC_URL if url.empty? && anthropic?(fields) && fields["host"].to_s.strip.empty? && fields["port"].to_s.strip.empty?
283
+ url = HostConfig::ANTHROPIC_URL if url.empty? && anthropic?(fields) && fields["host"].to_s.strip.empty? && fields["port"].to_s.strip.empty?
284
284
  unless url.empty?
285
285
  uri = URI.parse(url)
286
286
  path = uri.path.to_s.chomp("/")
@@ -337,8 +337,8 @@ module Samagotchi
337
337
  @exit = usage_error(e.message)
338
338
  return nil
339
339
  end
340
- explain_name(base, name, reason)
341
340
  outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
341
+ explain_name(base, name, reason) unless outcome.kind == :exists
342
342
  code = report(outcome, model)
343
343
  return code unless INSTALLS_BUNDLES.include?(outcome.kind)
344
344
 
@@ -176,12 +176,12 @@ rules:
176
176
  # ── answering chi's own questions around chi answer ───────────────────
177
177
  # A parent agent answers a child's approval with chi answer, which holds it
178
178
  # to guardrails.parent_approvals. These shapes go around that: chi --attach
179
- # or chi -p with stdin from a pipe, a here-string or a file (a chi after 0.12 treats
180
- # those as a parent too, but an older child doesn't), and a POST to the
181
- # Bridge's or the web's answer route.
179
+ # or chi -p with stdin from a pipe, a here-string or a file other than
180
+ # /dev/null (a chi after 0.12 treats those as a parent too, but an older
181
+ # child doesn't), and a POST to the Bridge's or the web's answer route.
182
182
  - id: chi-answer-piped
183
183
  tool: shell
184
- command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<)'
184
+ command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<(?!\s*/dev/null(?![\w./-])))'
185
185
  verdict: ask
186
186
  scopes: [once, session]
187
187
  reason: answers chi's questions from a pipe or a file, around chi answer
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.9.0
3
+ version: 0.9.1
4
4
  scope: system
5
5
  description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory, writing a memory that reaches the system prompt (identity, model notes) or a model overlay; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: slow-commands
3
- version: 0.2.2
3
+ version: 0.2.3
4
4
  scope: system
5
5
  description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:6bf5da2501a6404cda01c95975b005fce179f756c56222771c5ee857d7ba4b98
9
+ sha256: sha256:249615f1d8529ab020ea9872bb42b00463594f20a9ec44fecc4e0f58d34dce91
10
10
  # Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
11
11
  # call:, elapsed_ms: and result: (0.57.0)
12
12
  requires_chi: ">= 0.59.0"
@@ -21,7 +21,8 @@
21
21
  # `env X=… cmd` is `X=… cmd`, an `env` with -C/-i/-u (or any other option)
22
22
  # stays as typed. The entry's `shown` is the last run as typed. Input that ends inside a quote, $(…)
23
23
  # or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
24
- # cat/tee read as data: ShellLex makes those one placeholder, so different
24
+ # a data reader reads as data (ShellLex: cat, tee, git commit -F -, gh …
25
+ # --body-file -): ShellLex makes those one placeholder, so different
25
26
  # commands would share a key.
26
27
  #
27
28
  # after_tool_call records: timed out (killed at the limit), handed over as
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.80.0
3
+ version: 0.82.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:
@@ -62,19 +62,25 @@ module Samagotchi
62
62
  # a --wait with no message is about (ReplyWait.own_turn)
63
63
  # @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
64
64
  # queued:)
65
+ # @param ended_before [Boolean] a last_turn that ended before the
66
+ # baseline is the answer (ReplyWait's ended_before:) — the
67
+ # --wait-with-no-message's settled session
68
+ # @param report_turn_id [String, nil] the turn id to include in the
69
+ # report, even when ReplyWait uses the next-reply path
65
70
  # @return [Integer] the exit status
66
71
  def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
67
- queued: false)
72
+ queued: false, ended_before: false, report_turn_id: turn)
68
73
  result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
69
74
  owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
70
- queued: queued)
75
+ queued: queued, ended_before: ended_before)
76
+ result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
71
77
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
72
78
  @reported = true
73
79
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
74
80
  json: @json, timeout: timeout, extra: report_extra)
75
81
  rescue Interrupt
76
82
  if @json
77
- @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id,
83
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
78
84
  extra: report_extra))
79
85
  @stdout.flush
80
86
  @reported = true
@@ -552,6 +552,30 @@ module Samagotchi
552
552
  @session_observer.notify(type: :recap_ready, recap: recap, generation: generation, covered: covered)
553
553
  end
554
554
 
555
+ # Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
556
+ # on_usage) in the session's side line, apart from its turns' totals.
557
+ # Folded and announced as :side_usage under the event lock, so the side
558
+ # blocks reach the UIs in fold order (the lock order event lock →
559
+ # metrics mutex is the existing one); then, between turns, saved to
560
+ # analytics.json (a running turn's end or checkpoint saves it), unless
561
+ # the session is gone (no session file: a late recap thread must not
562
+ # recreate a deleted session's folder; the folder itself may not exist
563
+ # yet before a first turn). Safe from any thread: the recap's, a
564
+ # plugin's, the turn's.
565
+ # @param usage [SideUsage]
566
+ # @return [void]
567
+ def note_side_usage(usage)
568
+ synchronize_events do
569
+ block = @metrics.add_side(usage)
570
+ @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
571
+ end
572
+ sid = @metrics.session_id
573
+ return if sid.nil? || turn_running?
574
+ return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
575
+
576
+ @metrics.persist(state_dir: session_state_dir)
577
+ end
578
+
555
579
  # @return [SessionMetrics] the per-session analytics collector
556
580
  attr_reader :metrics
557
581
 
@@ -2544,9 +2568,9 @@ module Samagotchi
2544
2568
  declared_tools: -> { toolkit_explained.declared.map(&:name) },
2545
2569
  use_toolkit: ->(name) { use_toolkit(name) },
2546
2570
  cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
2547
- ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil|
2571
+ ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
2548
2572
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
2549
- model: model)
2573
+ model: model, source: source)
2550
2574
  },
2551
2575
  model_name: -> { effective_model_ref },
2552
2576
  model_key: -> { @model_key },
@@ -2591,13 +2615,15 @@ module Samagotchi
2591
2615
  # IdleClient, so it shares nothing with the turn's backend. The client
2592
2616
  # is kept while the target and timeout stay (what it learned, e.g. a
2593
2617
  # refused thinking-off, holds for the next request); the session's id
2594
- # goes with the request.
2618
+ # goes with the request. Its usage counts in the session's side line
2619
+ # (#note_side_usage) under +source+, the asking bundle's name.
2595
2620
  # @return [String] the answer
2596
- def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil)
2621
+ def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
2597
2622
  target = model.nil? ? session_model_recap_target : configured_side_target(model)
2598
2623
  client = side_model_client(target, timeout)
2599
2624
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2600
- answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id)
2625
+ answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
2626
+ kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
2601
2627
  Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
2602
2628
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
2603
2629
  answer.text