samagotchi 0.80.0 → 0.81.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +9 -1
  4. data/docs/cli.md +8 -0
  5. data/docs/configuration.md +3 -0
  6. data/docs/guardrails.md +26 -3
  7. data/docs/plugins.md +8 -3
  8. data/docs/sessions.md +8 -0
  9. data/docs/testing.md +19 -0
  10. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +1 -1
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/engine.rb +31 -5
  18. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  19. data/lib/samagotchi/guardrails/rules.rb +4 -2
  20. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  21. data/lib/samagotchi/guardrails/targets.rb +14 -0
  22. data/lib/samagotchi/host_config.rb +8 -8
  23. data/lib/samagotchi/idle_client.rb +41 -11
  24. data/lib/samagotchi/idle_recap.rb +6 -1
  25. data/lib/samagotchi/idle_target.rb +12 -4
  26. data/lib/samagotchi/llm/usage.rb +9 -3
  27. data/lib/samagotchi/model_price.rb +16 -0
  28. data/lib/samagotchi/plugin/context.rb +10 -3
  29. data/lib/samagotchi/plugin/service.rb +24 -1
  30. data/lib/samagotchi/recap_setup.rb +2 -1
  31. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  32. data/lib/samagotchi/session_metrics.rb +46 -12
  33. data/lib/samagotchi/session_observer.rb +3 -0
  34. data/lib/samagotchi/side_usage.rb +38 -0
  35. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  36. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  37. data/lib/samagotchi/testing/host.rb +2 -2
  38. data/lib/samagotchi/testing/recorder.rb +7 -4
  39. data/lib/samagotchi/testing/sandbox.rb +5 -3
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +4 -1
  42. data/lib/samagotchi/web/public/ctx.js +32 -6
  43. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  44. data/lib/samagotchi/web/public/lists_view.js +1 -1
  45. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  46. data/lib/samagotchi/web/public/timing.js +6 -1
  47. data/lib/samagotchi/web/session_summary.rb +2 -0
  48. 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: e0e5c6acd4a2774291e421b71b7b5729b0e3031c9afaa46715f40a1d976ebed2
4
+ data.tar.gz: 6390322560ddd65a847ea4d3a122b3751be778c40eb573d457c617880daa0e06
5
5
  SHA512:
6
- metadata.gz: 4c936b332ee7cafb74cb4e09b7f3b80acac7ba1247988e755078c61e72c201489b1a48f8970c6c3d70d88b7cedc31cf432d01c5b14018073a517b65f76e45de6
7
- data.tar.gz: dc77457ac7bf6a0018966bef40ccaa67da732a43edbe21e0fbcef1193583a3e00934c70c4230d3fcf14f5dd23012730f9c41bb16be8d7a64754f361ff3c2ab4a
6
+ metadata.gz: c1d209965a45e687b89280cbcecfe0b51798dcff1a418837b3604f4b3b1e69e8d0f210d8b4972c84912e274c7f738c322a2270af36833fe17049e999081a2705
7
+ data.tar.gz: 9c5d2a4e2205da68d70d99db385ab3f79ebec1f83f8762baaf36d3ae466fc98204c68bcd93ab1868127e703f5d50eaed0dd363ac910541849298c3bbc72bb931
data/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.81.0] - 2026-10-18
12
+
13
+ ### Added
14
+
15
+ - Stats: side requests (the idle recap, `/btw` and plugins' `ctx.ask_model`) are counted on their own
16
+ `side requests:` line in `/stats` and the web's ctx tooltip, with their tokens and cost; the session's totals stay
17
+ its turns'.
18
+
19
+ ### Fixed
20
+
21
+ - Guardrails: a `command:` rule no longer matches the text of a heredoc its command only reads as data
22
+ (`git commit -F -`, `gh pr create --body-file -`, `cat` to stdout, a `"$(cat <<'EOF' …)"` commit message); a
23
+ heredoc fed to a shell or an interpreter, or piped on (also from a `( … )` or `{ … }` group), is still
24
+ matched. The bundle's `chi-answer-piped` (guardrails bundle 0.9.1) no longer asks about stdin from
25
+ `/dev/null`.
26
+ - Guardrails: the `rm` and `touches` checks now see a heredoc that bash runs after all — piped on from a `( … )` or
27
+ `{ … }` group, fed through `<( … )` / `>( … )`, or written with `&>` / `>& file` and then run — which they used to
28
+ read as plain data.
29
+ - Plugins: a `chi.service` that starts lazily from a tool or hook handler runs its start block outside the handler,
30
+ so Stop no longer cancels the service's startup `ctx.http` calls (as docs/plugins.md says).
31
+ - Bundles: the test kit's sandbox also points `XDG_DATA_HOME` and `XDG_CACHE_HOME` at its temp folder, so a bundle
32
+ test no longer writes to your own data or cache home when you set them.
33
+ - `chi bootstrap`: re-running it against a server the config already has says so, without the misleading "saved as …"
34
+ line before it.
35
+
36
+ Update with `chi update` (the system bundle 0.81.0, `guardrails` 0.9.1, `slow-commands` 0.2.3), then restart
37
+ `chi web`.
38
+
11
39
  ## [0.80.0] - 2026-10-17
12
40
 
13
41
  ### Added
@@ -3310,7 +3338,8 @@ and long-lived sessions.
3310
3338
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3311
3339
  hotkey panel that send selected text or the clipboard to your sessions.
3312
3340
 
3313
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.80.0...HEAD
3341
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.81.0...HEAD
3342
+ [0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
3314
3343
  [0.80.0]: https://github.com/dm1try/samagotchi/compare/v0.79.0...v0.80.0
3315
3344
  [0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
3316
3345
  [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
@@ -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,9 @@ 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. They count on `/stats`' own `side requests:` line, never in the session's `cost:`.
576
579
 
577
580
  ## Thinking
578
581
 
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.
@@ -1164,8 +1169,8 @@ bundle.shutdown
1164
1169
  `bundle.set(cancelled: true)` makes a call without `cancel:` raise `HttpCancelled`.
1165
1170
  - **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
1166
1171
  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
1172
+ - **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
1173
+ debug log), `HOME`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, no git config of yours, no Keychain and none of your
1169
1174
  `SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml.
1170
1175
  - **RSpec:** `require "samagotchi/testing/rspec"`; example groups tagged `:bundle` (a spec under a bundle's own
1171
1176
  `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.
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.81.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:
@@ -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 = @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
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # Which shell commands read their stdin as data and never run it: cat and
6
+ # tee (to stdout), and a git or gh command taking its message from stdin.
7
+ # Pure word predicates over one simple command's words (the lexer's
8
+ # ShellLex). Everything else is an unknown consumer and stays matched.
9
+ module DataReaders
10
+ # Commands that print what they read.
11
+ SINKS = %w[cat tee].freeze
12
+ # What a heredoc body that a data reader reads becomes (the same value
13
+ # as ShellLex::HEREDOC).
14
+ HEREDOC = "\0HEREDOC"
15
+ # A word that is a redirection (>, >>, 2>&1, <; the same as
16
+ # ShellLex::REDIRECT).
17
+ REDIRECT = /\A\d*[<>]/
18
+ # git's global options that take the next word.
19
+ GIT_OPTIONS_WITH_VALUE = %w[-C -c --git-dir --work-tree --namespace].freeze
20
+ # A >& target that names an fd instead of a file: a number or -.
21
+ FD_TARGET = /\A(\d+|-)\z/
22
+
23
+ module_function
24
+
25
+ # The command word among a simple command's words: the NAME= words and
26
+ # the redirections dropped, and with a redirection written with a space
27
+ # (> /tmp/x) its target word too. nil when there is none.
28
+ def command_word(words)
29
+ index = command_index(words)
30
+ words[index] if index
31
+ end
32
+
33
+ # Where command_word's word is in +words+ (nil when there is none).
34
+ def command_index(words)
35
+ i = 0
36
+ while i < words.size
37
+ word = words[i]
38
+ if word.match?(REDIRECT)
39
+ i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
40
+ elsif !word.match?(/\A[A-Za-z_]\w*=/)
41
+ return i
42
+ end
43
+ i += 1
44
+ end
45
+ nil
46
+ end
47
+
48
+ # Whether a cat or tee command sends its output to a file (a file
49
+ # written can be run later): a stdout redirection whose target isn't
50
+ # /dev/null, a dangling >, or a tee argument that is no option,
51
+ # redirection, heredoc or /dev/null.
52
+ def writes_file?(words)
53
+ return true if stdout_file?(words)
54
+
55
+ File.basename(command_word(words).to_s) == "tee" && tee_file?(words)
56
+ end
57
+
58
+ # Whether one of +words+ sends stdout to a file: a >, >>, 1>, 1>>, >& or
59
+ # 1>& redirection (its target in the word, or in the next word when the
60
+ # operator ends it) whose target names a file and isn't /dev/null. A dup
61
+ # to an fd writes no file: >&2, >&-, 2>&1, and a spaced ">& 2" or
62
+ # ">& -". A >& with any other target does write one: bash reads
63
+ # ">& file" and ">&file" as "&>file".
64
+ def stdout_file?(words)
65
+ words.each_with_index do |word, i|
66
+ match = word.match(/\A1?(>>?)(&?)(.*)\z/m)
67
+ next unless match
68
+
69
+ amp = match[2]
70
+ target = match[3]
71
+ target = words[i + 1] if target.empty? # "> file", ">& 2"
72
+ return true if target.nil? # a dangling > or >&
73
+ next if target == "/dev/null"
74
+ next if !amp.empty? && target.match?(FD_TARGET)
75
+
76
+ return true
77
+ end
78
+ false
79
+ end
80
+
81
+ # Whether a tee command writes a file of its own (an argument that
82
+ # isn't an option, a redirection, a heredoc, a body or /dev/null).
83
+ def tee_file?(words)
84
+ verb = command_index(words)
85
+ return false unless verb
86
+
87
+ i = verb + 1
88
+ while i < words.size
89
+ word = words[i]
90
+ if word.match?(REDIRECT)
91
+ i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
92
+ next
93
+ end
94
+ return true if filename_word?(word)
95
+
96
+ i += 1
97
+ end
98
+ false
99
+ end
100
+
101
+ # Whether a tee argument names a file: not an option, a heredoc's tag,
102
+ # a body word (HEREDOC, SUBST, a script) or /dev/null.
103
+ def filename_word?(word)
104
+ !word.start_with?("-", "<<") && !word.include?("\0") && !word.include?("\n") && word != "/dev/null"
105
+ end
106
+
107
+ # Whether a simple command reads its stdin as a text file it never
108
+ # runs: cat or tee, git commit/tag -F - (--file -), gh pr|issue … -F -
109
+ # (--body-file -) or gh release … -F - (--notes-file -).
110
+ def data_reader?(words)
111
+ args = arguments(words)
112
+ verb = File.basename(args.first.to_s)
113
+ return true if SINKS.include?(verb)
114
+
115
+ case verb
116
+ when "git" then git_reader?(args)
117
+ when "gh" then gh_reader?(args)
118
+ else false
119
+ end
120
+ end
121
+
122
+ # Whether word +index+ of a simple command is the value of a message
123
+ # option of a data reader: git commit/tag -m/--message, gh pr|issue …
124
+ # -b/--body/-t/--title, gh release … -n/--notes/-t/--title. A
125
+ # "$(cat <<'EOF' … EOF)" there is a message, not code.
126
+ def message_arg?(words, index)
127
+ return false unless index.positive?
128
+
129
+ option = words[index - 1]
130
+ args = arguments(words)
131
+ case File.basename(args.first.to_s)
132
+ when "git" then git_message_option?(args, option)
133
+ when "gh" then gh_message_option?(args, option)
134
+ else false
135
+ end
136
+ end
137
+
138
+ # A simple command's arguments: the NAME= words, the redirections (with
139
+ # their spaced targets), the <<TAG words and the heredoc bodies left
140
+ # out.
141
+ def arguments(words)
142
+ args = []
143
+ i = 0
144
+ while i < words.size
145
+ word = words[i]
146
+ if word.match?(REDIRECT)
147
+ i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
148
+ next
149
+ end
150
+ args << word unless word.match?(/\A[A-Za-z_]\w*=/) || heredoc_word?(word)
151
+ i += 1
152
+ end
153
+ args
154
+ end
155
+
156
+ # Whether a word is a heredoc's <<TAG or one of its bodies.
157
+ def heredoc_word?(word)
158
+ word.match?(REDIRECT) || word.include?("\0") || word.include?("\n")
159
+ end
160
+
161
+ # The words after git's global options ([-C dir …] commit), nil when
162
+ # they run out.
163
+ def git_arguments(args)
164
+ i = 1
165
+ i += GIT_OPTIONS_WITH_VALUE.include?(args[i]) ? 2 : 1 while i < args.size && args[i].start_with?("-")
166
+ args[i..]
167
+ end
168
+
169
+ def git_reader?(args)
170
+ rest = git_arguments(args)
171
+ return false unless rest
172
+
173
+ subcommand, *opts = rest
174
+ return false unless %w[commit tag].include?(subcommand)
175
+
176
+ stdin_file?(opts, "-F", "--file")
177
+ end
178
+
179
+ def gh_reader?(args)
180
+ case args[1]
181
+ when "pr", "issue"
182
+ %w[create comment edit review].include?(args[2]) && stdin_file?(args.drop(3), "-F", "--body-file")
183
+ when "release"
184
+ %w[create edit].include?(args[2]) && stdin_file?(args.drop(3), "-F", "--notes-file")
185
+ else
186
+ false
187
+ end
188
+ end
189
+
190
+ # Whether +opts+ take their text from stdin: "-F -", "--file -",
191
+ # "-F-" or "--file=-".
192
+ def stdin_file?(opts, short, long)
193
+ opts.each_cons(2).any? { |a, b| [short, long].include?(a) && b == "-" } ||
194
+ opts.include?("#{short}-") || opts.include?("#{long}=-")
195
+ end
196
+
197
+ def git_message_option?(args, option)
198
+ rest = git_arguments(args)
199
+ rest && %w[commit tag].include?(rest.first) && %w[-m --message].include?(option)
200
+ end
201
+
202
+ def gh_message_option?(args, option)
203
+ case args[1]
204
+ when "pr", "issue"
205
+ %w[create comment edit review].include?(args[2]) && %w[-b --body -t --title].include?(option)
206
+ when "release"
207
+ %w[create edit].include?(args[2]) && %w[-n --notes -t --title].include?(option)
208
+ else
209
+ false
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -13,7 +13,9 @@ module Samagotchi
13
13
  # a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
14
14
  # matches the call's name or the tool a plugin tool's call
15
15
  # acts as (Targets#acts_as: an MCP tool behind call_tool)
16
- # command: a Ruby regex on a shell tool's command
16
+ # command: a Ruby regex on a shell tool's command, the body of a
17
+ # heredoc its command only reads as data left out
18
+ # (Targets#rule_command, ShellLex.code_text)
17
19
  # path: "outside_repo", or a glob on the resolved paths (absolute
18
20
  # or "**/…" globs match the absolute path; others the path
19
21
  # relative to the repo root, the cwd outside one)
@@ -61,7 +63,7 @@ module Samagotchi
61
63
  return false unless targets
62
64
  return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
63
65
  return false if tools && !tool_matches?(targets.tool) && !(targets.acts_as && tool_matches?(targets.acts_as))
64
- return false if command && !(targets.command && command.match?(targets.command))
66
+ return false if command && !(targets.rule_command && command.match?(targets.rule_command))
65
67
  return false if path && !path_matches?(targets)
66
68
  return false if git && !targets.git_outside_repo?
67
69
  return false if touches && !targets.touches_chi?