samagotchi 0.79.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -5
  4. data/docs/architecture.md +9 -1
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +10 -4
  7. data/docs/guardrails.md +26 -3
  8. data/docs/internals/anthropic.md +3 -1
  9. data/docs/internals/bundles.md +4 -0
  10. data/docs/internals/engine.md +13 -3
  11. data/docs/plugins.md +149 -3
  12. data/docs/releasing.md +2 -2
  13. data/docs/sessions.md +9 -1
  14. data/docs/testing.md +51 -2
  15. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  16. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  17. data/lib/samagotchi/bootstrap_command.rb +93 -16
  18. data/lib/samagotchi/bridge.rb +1 -1
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  25. data/lib/samagotchi/continue_offer.rb +5 -5
  26. data/lib/samagotchi/engine.rb +68 -39
  27. data/lib/samagotchi/guardrails/approval.rb +6 -4
  28. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  29. data/lib/samagotchi/guardrails/gate.rb +7 -3
  30. data/lib/samagotchi/guardrails/rules.rb +4 -2
  31. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  32. data/lib/samagotchi/guardrails/targets.rb +14 -0
  33. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  34. data/lib/samagotchi/hooks/registry.rb +44 -3
  35. data/lib/samagotchi/host_config.rb +8 -8
  36. data/lib/samagotchi/host_registry.rb +12 -4
  37. data/lib/samagotchi/idle_client.rb +41 -11
  38. data/lib/samagotchi/idle_recap.rb +6 -1
  39. data/lib/samagotchi/idle_target.rb +12 -4
  40. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  41. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  42. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  43. data/lib/samagotchi/llm/http.rb +12 -7
  44. data/lib/samagotchi/llm/usage.rb +9 -3
  45. data/lib/samagotchi/model_price.rb +16 -0
  46. data/lib/samagotchi/plugin/card.rb +44 -0
  47. data/lib/samagotchi/plugin/context.rb +61 -9
  48. data/lib/samagotchi/plugin/http.rb +154 -0
  49. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  50. data/lib/samagotchi/plugin/loader.rb +29 -10
  51. data/lib/samagotchi/plugin/service.rb +24 -1
  52. data/lib/samagotchi/question_answer.rb +29 -0
  53. data/lib/samagotchi/question_desk.rb +20 -19
  54. data/lib/samagotchi/recap_setup.rb +2 -1
  55. data/lib/samagotchi/relay_verifier.rb +1 -1
  56. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  57. data/lib/samagotchi/session_metrics.rb +46 -12
  58. data/lib/samagotchi/session_observer.rb +3 -0
  59. data/lib/samagotchi/side_usage.rb +38 -0
  60. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  61. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  62. data/lib/samagotchi/testing/boundary.rb +156 -0
  63. data/lib/samagotchi/testing/bundle.rb +349 -0
  64. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  65. data/lib/samagotchi/testing/fake_http.rb +137 -0
  66. data/lib/samagotchi/testing/helpers.rb +56 -0
  67. data/lib/samagotchi/testing/host.rb +76 -0
  68. data/lib/samagotchi/testing/minitest.rb +31 -0
  69. data/lib/samagotchi/testing/recorder.rb +141 -0
  70. data/lib/samagotchi/testing/recording_api.rb +27 -0
  71. data/lib/samagotchi/testing/rspec.rb +31 -0
  72. data/lib/samagotchi/testing/sandbox.rb +165 -0
  73. data/lib/samagotchi/testing/sessions.rb +77 -0
  74. data/lib/samagotchi/testing/verdict.rb +28 -0
  75. data/lib/samagotchi/testing/waiting.rb +36 -0
  76. data/lib/samagotchi/testing.rb +26 -0
  77. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  78. data/lib/samagotchi/tools/tool_index.rb +3 -0
  79. data/lib/samagotchi/turn_state.rb +28 -1
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +4 -1
  82. data/lib/samagotchi/web/public/app.js +47 -23
  83. data/lib/samagotchi/web/public/ctx.js +32 -6
  84. data/lib/samagotchi/web/public/images.js +33 -0
  85. data/lib/samagotchi/web/public/index.html +71 -38
  86. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  87. data/lib/samagotchi/web/public/level_chip.js +11 -11
  88. data/lib/samagotchi/web/public/lists_view.js +20 -15
  89. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  90. data/lib/samagotchi/web/public/model_picker.js +1 -1
  91. data/lib/samagotchi/web/public/scope.js +3 -3
  92. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  93. data/lib/samagotchi/web/public/strip.js +35 -9
  94. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  95. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  96. data/lib/samagotchi/web/public/timing.js +6 -1
  97. data/lib/samagotchi/web/session_summary.rb +2 -0
  98. metadata +24 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc74f7069433e0f6be4252973309183e17c3ecc7ad06800684bf9be6fe40015f
4
- data.tar.gz: 12a8d7e7c8002a8762d7752f6cae9bd20fae6565cb2d81636cc1ca9a6ff2469d
3
+ metadata.gz: e0e5c6acd4a2774291e421b71b7b5729b0e3031c9afaa46715f40a1d976ebed2
4
+ data.tar.gz: 6390322560ddd65a847ea4d3a122b3751be778c40eb573d457c617880daa0e06
5
5
  SHA512:
6
- metadata.gz: 004ef50c618ba8bb488adf9e5623da491691bf37850946870bb94511451d8e56876288232d0bae0212dcfc7d9a1c79544d4297ef0ab8692f5678ff3617fc144c
7
- data.tar.gz: cb551afd6bdae21bfd61a4ff64ead0306816b41b88f4b5083fbf5e7d2afbb9ef9b9353d5d5750ccbfd1764fe10db8c60f8eb534a2cd4d728b04e3145f84dad4a
6
+ metadata.gz: c1d209965a45e687b89280cbcecfe0b51798dcff1a418837b3604f4b3b1e69e8d0f210d8b4972c84912e274c7f738c322a2270af36833fe17049e999081a2705
7
+ data.tar.gz: 9c5d2a4e2205da68d70d99db385ab3f79ebec1f83f8762baaf36d3ae466fc98204c68bcd93ab1868127e703f5d50eaed0dd363ac910541849298c3bbc72bb931
data/CHANGELOG.md CHANGED
@@ -8,6 +8,62 @@ 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
+
39
+ ## [0.80.0] - 2026-10-17
40
+
41
+ ### Added
42
+
43
+ - Web: the message box has an image button next to Start / Send that opens a file picker (on a phone, the camera or
44
+ the photo library); the picked images are added like pasted ones. A message now takes at most 20 images of up to
45
+ 20 MB each as you add them, pasted, dropped or picked, and a toast names the files left out, instead of the send
46
+ failing.
47
+ - Bundles: `samagotchi/testing` (experimental), a test kit for bundles: load a bundle in a sandbox, fire its hooks, run its commands and tools, record steers, notices and cards (RSpec `:bundle` groups or Minitest `Testing::Case`; docs/plugins.md "Testing a bundle"). The shipped bundles' specs now live in each bundle's own `spec/` and use it; a boundary spec keeps bundles on the stable surface.
48
+ - Plugins: `ctx.http`, an HTTP client for calling services (`get`, `post_json`, `request`): short default timeouts, one error type (`Samagotchi::Plugin::HttpError`), a 4xx/5xx returned as a Response, one INFO `http` log line per call without query, headers or body (`purpose=plugin`, `bundle=`), and a call made for the turn stops when the turn is stopped (docs/plugins.md "Calling services"; experimental until 1.0). The test kit fakes it: `bundle.http.stub(…)`, `requests`, `unmatched`, and a request no stub answered fails the test.
49
+ - `chi bootstrap` sets up Anthropic Messages hosts: a server that refuses the OpenAI model list but lists Anthropic's
50
+ way (api.anthropic.com, a Messages-only gateway) is written as `api: anthropic` (the url without `/v1`, `auth: bearer`
51
+ when only Bearer worked), with `dialect: fireworks` for Fireworks' host or model ids. `--api openai|anthropic` and
52
+ `--dialect standard|fireworks` override the detection; direct Fireworks stays `api: openai` with a hint, and a test
53
+ turn's 404 on `/chat/completions` hints `--api anthropic`. A hand-written urlless `api: anthropic` host counts as
54
+ api.anthropic.com, so bootstrapping it again writes nothing.
55
+ - Plugins: `ctx.ask_model(model: "host:id")` asks a configured model (a host:id ref, an alias, or a model one of the hosts lists) instead of the session's; an unknown ref raises `ArgumentError`.
56
+
57
+ ### Changed
58
+
59
+ - Web: the start page says "+ new session" (and "new session in …") instead of "+ new chat", matching the rest of the UI.
60
+ - Web: the latest-sessions strip hides and shows with the same edge grip as the composer's (a drag up hides it, a drag
61
+ down or a click shows it, Enter/Space on the focused grip toggles, and the choice is remembered): the tile's `hide ▴`
62
+ and the top bar's `▴ hide` are gone, and while the strip is hidden its pill — now only its status — opens All
63
+ sessions in one click instead of showing the strip first.
64
+
65
+ Update with `chi update` (the system bundle 0.80.0), then restart `chi web`.
66
+
11
67
  ## [0.79.0] - 2026-10-17
12
68
 
13
69
  ### Fixed
@@ -3282,7 +3338,9 @@ and long-lived sessions.
3282
3338
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3283
3339
  hotkey panel that send selected text or the clipboard to your sessions.
3284
3340
 
3285
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.79.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
3343
+ [0.80.0]: https://github.com/dm1try/samagotchi/compare/v0.79.0...v0.80.0
3286
3344
  [0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
3287
3345
  [0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
3288
3346
  [0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
data/README.md CHANGED
@@ -69,10 +69,12 @@ Point chi at your model server; it works out the rest and writes
69
69
  chi bootstrap 192.168.1.29:8081 # llama.cpp on another machine
70
70
  chi bootstrap localhost:11434 # any OpenAI-compatible server
71
71
  chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
72
+ chi bootstrap https://api.anthropic.com --key-env ANTHROPIC_API_KEY
72
73
  chi bootstrap # look on this machine's usual ports
73
74
  ```
74
75
 
75
- It finds out whether the server is llama.cpp or OpenAI-compatible, picks the
76
+ It finds out whether the server is llama.cpp, OpenAI-compatible or Anthropic's
77
+ Messages API, picks the
76
78
  model (or asks, when there are several), sends one test request and writes
77
79
  the config. With a config already there, it adds a `hosts:` entry and keeps
78
80
  the rest of the file. Then it installs the `core` bundles (loop-guard,
@@ -127,7 +129,7 @@ pbpaste | chi send -m "same bug?" <id> # a message to a session, the clipboar
127
129
  chi send --new --wait -m "review feat/x" # a new session you can watch in the web; prints the answer
128
130
  ```
129
131
 
130
- `@shot.png` in a prompt (or a pasted/dropped image in the web UI) shows the model an image, when it can see them; see [Images](docs/cli.md#images).
132
+ `@shot.png` in a prompt (or a pasted, dropped or picked image in the web UI) shows the model an image, when it can see them; see [Images](docs/cli.md#images).
131
133
 
132
134
  `/model` switches models, Ctrl-C cancels a turn, and Ctrl-D or `/detach` detaches (the session keeps running; `chi --attach ID` comes back). `/exit` detaches and stops the session's worker too, unless something still needs it (a running turn, another UI); `chi --resume ID` picks the conversation up again. `/exit --delete` also deletes the session once the worker has gone; `chi sessions delete ID` deletes one from the shell. `/archive` (or `chi sessions archive ID`) hides a session from every list and keeps it for good; `chi sessions list --archived` finds it again.
133
135
 
@@ -220,9 +222,9 @@ once, for the session, for the repo, or for the whole rule in the repo.
220
222
  ## Development
221
223
 
222
224
  ```sh
223
- bundle exec rspec # Ruby specs
224
- npm test # web frontend specs
225
- npm run e2e # web UI happy paths in Chromium, fake model (once: npx playwright install chromium)
225
+ bundle exec rspec spec lib/samagotchi/bundles/*/spec # Ruby specs (chi's and the shipped bundles' own)
226
+ npm test # web frontend specs
227
+ npm run e2e # web UI happy paths in Chromium, fake model (once: npx playwright install chromium)
226
228
  ```
227
229
 
228
230
  Integration specs against a live model server, and what the suite isolates: [docs/testing.md](docs/testing.md).
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Commands
4
4
 
5
- - `chi bootstrap [HOST[:PORT]|URL]` — first setup: find the model server (llama.cpp or OpenAI-compatible), pick the model, send a test request and write config.yml, or add a `hosts:` entry to an existing one (see [First setup](#first-setup))
5
+ - `chi bootstrap [HOST[:PORT]|URL]` — first setup: find the model server (llama.cpp, OpenAI-compatible or Anthropic Messages), pick the model, send a test request and write config.yml, or add a `hosts:` entry to an existing one (see [First setup](#first-setup))
6
6
  - `chi` — start a session in a background worker and attach the terminal to it, so the Web UI (or another terminal) can share it (see [Sharing a session](#sharing-a-session))
7
7
  - `chi -p "your prompt"` — run a prompt, then stay attached
8
8
  - `chi -p "your prompt" --non-interactive` — run a prompt, print the answer, exit
@@ -33,12 +33,23 @@
33
33
  ```sh
34
34
  chi bootstrap 192.168.1.29:8081 # host:port (an IP or localhost: port 8080 when none; a domain: https 443, then http 80)
35
35
  chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
36
+ chi bootstrap https://api.anthropic.com --key-env ANTHROPIC_API_KEY
36
37
  chi bootstrap # try localhost 8080, 11434, 1234, 8000
37
38
  ```
38
39
 
39
40
  - **What it is.** llama.cpp's `/props` answering means the native API (no
40
41
  `api:`); otherwise `GET /v1/models` answering means an OpenAI-compatible
41
- server (`api: openai`). A URL with a path is the API base as given
42
+ server (`api: openai`). Only when that list is refused with 400, 401 or 403
43
+ does chi ask Anthropic's way (`anthropic-version`, the key in `x-api-key`,
44
+ once more as Bearer after a 401/403): a list there means Anthropic's
45
+ Messages API (`api: anthropic`, the url without `/v1`; `auth: bearer` when
46
+ only Bearer worked). A server that serves both, such as Splash, stays
47
+ `api: openai`. Fireworks (its host, or model ids under `accounts/fireworks/`)
48
+ on the Messages API gets `dialect: fireworks`; direct Fireworks is found as
49
+ OpenAI-compatible first and stays `api: openai`, with a line saying
50
+ `--api anthropic` takes its Messages API instead. `--api openai|anthropic`
51
+ and `--dialect standard|fireworks` (which implies `--api anthropic`) skip the
52
+ detection. A URL with a path is the API base as given
42
53
  (`…/api/v1`); a domain without a scheme is tried over https, then http.
43
54
  Each request is tried once, with 5 s timeouts, so a refused port answers
44
55
  at once.
@@ -51,8 +62,11 @@ chi bootstrap # try localhost 8080, 11434, 1234, 8000
51
62
  (a terminal gets a numbered list, a script the ids and exit 2). A llama.cpp
52
63
  server also shows its context size and the prompt profile its chat template
53
64
  matches.
54
- - **The test.** One short chat request ("test: answered in 1.1 s"); `--no-test`
55
- skips it. A failed test still writes the config, says so and exits 1.
65
+ - **The test.** One short chat request ("test: answered in 1.1 s"; `POST /v1/messages`
66
+ on `api: anthropic`); `--no-test` skips it. A failed test still writes the config,
67
+ says so and exits 1. A server found as OpenAI-compatible whose `/chat/completions`
68
+ answers 404 gets the hint `try --api anthropic` (a gateway's Messages url that also
69
+ lists the OpenAI way).
56
70
  - **The file.** With no config.yml it writes a small commented one:
57
71
  `default.model` as `<host>:<model>` and one `hosts:` entry named `local`
58
72
  (localhost), `lan` (an IP) or after the domain (`openrouter`); `--name`
@@ -472,6 +486,14 @@ Generations under 8 tokens or 100 ms get no speed of their own, nor does a
472
486
  reply without server token counts or a non-streamed one. A delegated child
473
487
  keeps its own numbers: a parent's tokens and cost leave them out.
474
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
+
475
497
  After a `/model` switch (or a hook's) the tokens line becomes a `models:` block, a row per run of turns on one
476
498
  model, in order (A → B → A is three rows): the model (`host:model` as asked), when (`10:43–now` while it runs; with
477
499
  the day when the rows span several), turns, tokens in/out, its average speed and cost. Under the row of the model
@@ -662,9 +684,12 @@ A model that can see images gets them these ways:
662
684
  - **A path in plain words**: "check /home/me/shot.png and describe it". The
663
685
  model calls `read` on it and sees the picture; the tool line ends in
664
686
  `→ image 1280×800`.
665
- - **The Web UI**: paste or drop images into the composer. Each shows as a chip
666
- (× removes it) and is sent with the message; an image alone is sent as
667
- `[image: name]`. Messages show thumbnails; a click opens one full size.
687
+ - **The Web UI**: paste or drop images into the composer, or pick them with
688
+ its image button (left of Start / Send; on a phone it offers the camera and
689
+ the photo library, and is hidden while a turn runs). Each shows as a chip (× removes it) and is sent with the
690
+ message; an image alone is sent as `[image: name]`. A message takes up to 20
691
+ images of up to 20 MB each; a toast names the files left out (over a cap, or
692
+ not an image). Messages show thumbnails; a click opens one full size.
668
693
  - **`chi send --image PATH`** (repeatable, up to 20) with a message, from a
669
694
  script or another terminal: `chi send --image shot.png -m "why is this red?"
670
695
  3fa2`. The attached terminal and the web show it like an image typed there.
@@ -10,8 +10,8 @@ Chi reads its settings from three places; the first that sets a value wins:
10
10
 
11
11
  then the built-in default. [All settings](#all-settings) lists them.
12
12
 
13
- `chi bootstrap HOST[:PORT]` writes a first config for a model server, or adds
14
- it to this file as a `hosts:` entry (see [First setup](cli.md#first-setup)).
13
+ `chi bootstrap HOST[:PORT]` writes a first config for a model server (llama.cpp, OpenAI-compatible or Anthropic
14
+ Messages), or adds it to this file as a `hosts:` entry (see [First setup](cli.md#first-setup)).
15
15
 
16
16
  Default path:
17
17
 
@@ -343,6 +343,9 @@ hosts:
343
343
 
344
344
  `auth:` works on any host (`bearer` is the default everywhere but `api: anthropic`).
345
345
 
346
+ `chi bootstrap https://api.anthropic.com --key-env ANTHROPIC_API_KEY` writes such an entry (a gateway: its url;
347
+ `--api anthropic` / `--dialect fireworks` when the detection can't tell, see [First setup](cli.md#first-setup)).
348
+
346
349
  **Claude 5.x preserved thinking:** Claude 5.x models (Opus, Sonnet and Haiku 5.5, Fable 5.1) bind each thinking
347
350
  block to everything before it: the system prompt, the tools and the earlier messages. chi keeps the history
348
351
  append-only, but the system prompt or the tools can change under a session: a resume or a worker respawn after
@@ -570,6 +573,9 @@ hosts:
570
573
  - The estimate is saved apart from the reported cost (`cost_estimate` per turn, `cost_estimate_sum` in the
571
574
  session's totals in `analytics.json`) and shows with a `~`.
572
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:`.
573
579
 
574
580
  ## Thinking
575
581
 
@@ -1467,8 +1473,8 @@ its own). The first target is a long session such as a day-long coordinator, whe
1467
1473
  (the same flags on `chi send --new`), in the session with `/llm-context strategy … apply … budget …`
1468
1474
  (docs/cli.md, "Session commands"), or from the web's `llm ctx` chip in the info bar.
1469
1475
  - **A new web chat:** the start page's `llm ctx` chip, beside the model picker, shows the picked model's values
1470
- and follows a model pick. Its form (titled "new chat") sets a choice for that one chat: the chip reads
1471
- `llm ctx stale · new chat` in the accent colour, and the create sends it as `POST /api/sessions`'s
1476
+ and follows a model pick. Its form (titled "new session") sets a choice for that one chat: the chip reads
1477
+ `llm ctx stale · new session` in the accent colour, and the create sends it as `POST /api/sessions`'s
1472
1478
  `llm_context` (`{"strategy": "stale,forget", "apply": "turn_end", "budget": "64k"}`, each a word as
1473
1479
  `/llm-context` takes it, `default` for unset; anything else answers `400 invalid_llm_context` and starts
1474
1480
  nothing), so the session file holds it before the first turn runs. It is not remembered: it resets after a
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
@@ -180,7 +180,9 @@ Why not send drop_block on every request: it needs adaptive or enabled thinking
180
180
  thinking field, `off` sends disabled), it would put a beta header on every request to every host, and it would drop
181
181
  the same blocks again on every request until something pruned them. The reactive form does the same work in one
182
182
  place, for one extra 400 round trip per break. The `anthropic-thinking-prefix-mismatch` diagnosis header (on the
183
- 400, undocumented) is kept on the provider error and logged as `diagnosis` on `thinking_recovered`.
183
+ 400, undocumented) is named by the adapter to its `LLM::HTTP` (`diagnosis_header:`, from
184
+ `AnthropicBindingRecovery::DIAGNOSIS_HEADER`), which keeps it on the provider error; it is logged as `diagnosis` on
185
+ `thinking_recovered`. The shared transport reads no provider header of its own.
184
186
 
185
187
  ## Cache breakpoints
186
188
 
@@ -44,6 +44,10 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
44
44
  Readers of the installed state: `Hooks::BundleLoader` (Engine), `GuardrailWiring#bundle_rules`, `Plugin::Loader`,
45
45
  `ContextProviders` (scripts), `BundleNeeds` (index markers). Each goes through `Provenance.each_installed` / `#record`
46
46
  and checks the recorded sha256 (and `requires_chi`) before loading.
47
+ The checks live in `Hooks::BundleLoader.load` and `Plugin::Loader.load_bundle`; the loading itself is
48
+ `BundleLoader.load_hook_file` (one hook file, into a given `namespace:`) and `Plugin::Loader.register_file` (one
49
+ plugin file: build, `register`, `commit!`), which raise instead of logging: the bundle test kit
50
+ (`Samagotchi::Testing::Bundle`) loads a bundle's source through them, unchecked.
47
51
 
48
52
  ## Installer#run, in order
49
53
 
@@ -350,14 +350,22 @@ read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs.
350
350
  TurnState
351
351
  ├── running?
352
352
  ├── controller (the running turn's CancellationController)
353
+ ├── thread, ended (the turn's own thread; its end event is out: post-turn work)
353
354
  ├── sink (the turn's on_event)
354
355
  ├── steers (plugin steers for the running turn's next boundary)
355
356
  ├── carried_steers (for the next turn that begins)
356
357
  └── activity clock (last_activity_at, activity_seq)
357
358
  ```
358
359
 
359
- - `begin!(controller:, sink:)`: running, with controller and sink, in one step; carried steers become steers.
360
- - `finish!`: not running, controller and sink cleared; returns the steers never taken (logged as dropped).
360
+ - `begin!(controller:, sink:)`: running, with controller and sink (and the calling thread, the turn's own), in one
361
+ step; carried steers become steers.
362
+ - `ended!`: the turn's end is decided (`end_turn`, next to `turn.ended`): its after_turn / session_end hooks run
363
+ after it and before `finish!`.
364
+ - `own_controller(handler:)`: the controller for work done for the turn itself, which a plugin's `ctx.http` call
365
+ without `cancel:` stops with (`Plugin::Host#cancel_controller`, `Engine#plugin_cancel_controller`): the turn's, on
366
+ its thread or inside a plugin handler, until `ended!`; nil elsewhere (an anytime command, a service, a plugin's
367
+ own thread) and outside a turn. An init task's own controller wins inside one.
368
+ - `finish!`: not running, controller, thread and sink cleared; returns the steers never taken (logged as dropped).
361
369
  - `steer(text, source:)`: queues a steer for the running turn (false with no turn).
362
370
  - `steer_next_turn(text, source:)`: queues a steer for the next turn that begins.
363
371
 
@@ -515,7 +523,8 @@ Engine initialization (in this order, among other things):
515
523
  @tools = Tools::Builtins.registry; @command_registry (SessionCommands' built-ins); @plugin_tasks
516
524
  @toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
517
525
  @thinking_notices (ThinkingNotices); @tool_index = Tools::ToolIndex.new; @peer_sources = PeerSources.new
518
- the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins),
526
+ the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins; each plugin's ctx is a
527
+ Plugin::Context over plugin_host, whose ctx.sessions it builds itself: the test kit passes its own, sessions:),
519
528
  then (plugins or not) @tool_index.declare(@tools): find_tools + call_tool (source "index", not core),
520
529
  declared per toolkit by Registry#declared? (a source registered at load, or a reachable built-in) and
521
530
  sets call_tool's unwrap (Tools::ToolIndex: the chi group from Registry#reachable_names, the sources'
@@ -728,6 +737,7 @@ Paths are under `lib/samagotchi/`.
728
737
  | `toolkit_binding.rb` | `ToolkitBinding`: the Engine's toolkit seam (the kits, `#explained`, `ctx.use_toolkit`, the turn-start `#apply!` behind `Engine#apply_toolkit!`) |
729
738
  | `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
730
739
  | `hook_routes.rb` | `HookRoutes`: the Engine's routes for a hook's or plugin's notice (`:hook_notice`, `fallback_for:`), question, stop and steer, and the guardrails' after-load line |
740
+ | `question_desk.rb`, `pending_question.rb`, `question_answer.rb` | `QuestionDesk`: the one open question (ask, post, answer, cancel, relay mark); `PendingQuestion` and `QuestionAnswer`, its values (Hashes only in events, Bridge JSON, the session file and the hook/plugin `ask_user` result) |
731
741
  | `event_routes.rb` | `EventRoutes`: where a card (`Engine#show_card`), a notice (`HookRoutes#notify`) and a guardrails line (`#guardrail_notify`, turn or between turns only) go: held while the plugins load, an anytime command's, an init task's, a running turn's event, else announced or held for `holding_announcements` |
732
742
  | `engine_peers.rb`, `peer_sources.rb` | `Engine::PeerView` (what the kernel's peer tools see of the Engine) and `Engine::RelayPeer` (a delegate's approval relay); `PeerSources`: the attached context sources a peer bundle declares as its peer's answers (`chi.peer_source`) |
733
743
  | `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
data/docs/plugins.md CHANGED
@@ -744,6 +744,7 @@ full label, `plugin.rb (bundle my-bundle)`.
744
744
  | `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason; true when it stopped one now. In a `:before_tool_call` block it also denies that call |
745
745
  | `ctx.stop_generation(reason)` | cut the generation that is streaming: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
746
746
  | `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
747
+ | `ctx.http` | an HTTP client for calling services (`get`, `post_json`, `request`): see [Calling services](#calling-services-ctxhttp) |
747
748
  | `ctx.sessions` | fork, send to, read, list and stop other sessions: see [Other sessions](#other-sessions-ctxsessions) |
748
749
  | `ctx.toolkit` | the name of the session's toolkit (`full` for a session from before toolkits); nil with no session |
749
750
  | `ctx.declared_tools` | the tool names the session's next turn declares (its toolkit resolved, with its own adds and removes): ask this, not the name, whether a tool is there |
@@ -842,6 +843,10 @@ next turn never see it, and no hook fires. It returns the answer text.
842
843
  - `max_tokens:` defaults to 1024. An answer cut off by it ends with `…`.
843
844
  - `cancel:` takes a `Samagotchi::CancellationController`; cancelling it
844
845
  aborts the request.
846
+ - `model:` names a configured model instead of the session's: `"host:id"` for a host in config.yml `hosts:`, an
847
+ alias from `models:`, or a bare id one of the hosts lists or declares. The host's own key and settings are used;
848
+ a plugin never names a URL or a key here (that is [`ctx.http`](#calling-services-ctxhttp)). A ref that names
849
+ nothing configured raises `ArgumentError`, asking nothing: a typo never goes to another model.
845
850
  - It blocks until the answer comes, so call it from an anytime command or a
846
851
  thread of your own. A local server that runs one request at a time
847
852
  (llama.cpp with one slot) answers it after a running turn's current
@@ -853,6 +858,83 @@ next turn never see it, and no hook fires. It returns the answer text.
853
858
  It goes through the host's OpenAI API (`/v1/chat/completions`), which
854
859
  llama.cpp serves on a native host too.
855
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
+
865
+ ## Calling services: `ctx.http`
866
+
867
+ ```ruby
868
+ # The http-echo bundle (spec fixture, docs/plugins.md "Calling services"):
869
+ # one tool that asks a local HTTP service and returns its answer.
870
+ class Plugin
871
+ def initialize(settings)
872
+ @url = settings.fetch("url", "http://127.0.0.1:9000/ask")
873
+ end
874
+
875
+ def register(chi)
876
+ chi.tool "ask_service", "Ask the local question service and return its answer",
877
+ params: { question: { type: "string", description: "What to ask", required: true } },
878
+ label: "asking" do |args, ctx|
879
+ res = ctx.http.post_json(@url, { question: args["question"] }, timeout: 5)
880
+ if res.ok?
881
+ res.json["answer"].to_s
882
+ else
883
+ ctx.log.warn(:ask_failed, status: res.status)
884
+ "the service answered #{res.status}"
885
+ end
886
+ rescue Samagotchi::Plugin::HttpError => e
887
+ "the service is not reachable: #{e.message}"
888
+ end
889
+ end
890
+ end
891
+ ```
892
+
893
+ That is `spec/fixtures/http_echo_bundle` (a spec keeps the two the same; `chi bundle install` it to try it): the
894
+ shape to copy for a plugin that calls a service, tested on `bundle.http` as in
895
+ `spec/samagotchi/plugin/http_echo_bundle_spec.rb`.
896
+
897
+ One way to call an HTTP service, with timeouts, errors, logging and Stop handled the same for every bundle.
898
+ `Net::HTTP` stays available for what this doesn't do (streaming, multipart, redirects, connection reuse).
899
+
900
+ | | |
901
+ |---|---|
902
+ | `ctx.http.get(url, headers: {}, timeout: 10)` | GET |
903
+ | `ctx.http.post_json(url, body, headers: {}, timeout: 10)` | POST `body` encoded as JSON, with `Content-Type` and `Accept: application/json` (unless `headers` names them) |
904
+ | `ctx.http.request(method, url, body: nil, headers: {}, timeout: 10)` | GET, POST, PUT, PATCH, DELETE or HEAD; `body` is a String sent as is (set your own `Content-Type`) |
905
+
906
+ Every call also takes `cancel:`, a `Samagotchi::CancellationController`, which replaces the default (below).
907
+
908
+ - `url` is a String or URI, `http` or `https`. A bad URL, another scheme, an unknown method, a bad header, a
909
+ `timeout` that isn't a positive number, or a body `post_json` can't encode as JSON raises **ArgumentError**
910
+ before any request.
911
+ - `timeout` (seconds) is the connect, the write and the **per-read** timeout, not a total deadline: a server that dribbles
912
+ bytes can take longer.
913
+ - It returns a `Samagotchi::Plugin::Http::Response`: `status` (Integer), `headers` (frozen, lower-case names,
914
+ repeated values joined with `", "`), `body` (a String, `""` for HEAD or no body), `ok?` (2xx) and `json`. A
915
+ **4xx or 5xx is returned, not raised**: check `ok?`. A redirect is not followed: the 3xx comes back with its
916
+ `location` header.
917
+ - `body` is tagged UTF-8 when its bytes are valid UTF-8, else left binary: `body.scrub` it before handing unknown
918
+ text to the model. There is no size limit: don't read an endpoint you don't trust to be small.
919
+ - `json` parses the body on each call and raises `HttpError` ("not JSON: …") when it isn't JSON.
920
+ - **One error type.** `Samagotchi::Plugin::HttpError` means the request didn't complete: refused, timed out, DNS,
921
+ TLS, reset, a malformed response, a bad compressed body, or `json` on a body that isn't JSON. No raw `Errno`,
922
+ `Net::` or `Zlib` error escapes (the original is the error's `cause`). The message says what and where
923
+ (`"POST 127.0.0.1:9000: connection refused"`), never headers or a body. `Samagotchi::Plugin::HttpCancelled`
924
+ (a subclass) means the call was cancelled.
925
+ - `http_proxy`, `https_proxy` and `no_proxy` are honoured as for chi's model calls (loopback is never proxied).
926
+ chi's own `User-Agent` is always sent: yours is replaced.
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
928
+ init task when chi shuts down; a call from your own thread, a `chi.service` or an anytime command has only its
929
+ timeout unless you pass `cancel:`. After the turn has ended (an `:after_turn` or `:session_end` hook of a
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.
932
+ - **The log.** Each call writes one `http` line to the debug log: INFO (ERROR when it fails) with `bundle=`,
933
+ `purpose=plugin`, the method, the URL **without its query**, the status and the time. Never headers or bodies.
934
+ A plugin that polls a dead service therefore logs an ERROR per poll.
935
+
936
+ `ctx.http` is experimental until 1.0, like the test kit.
937
+
856
938
  ## Other sessions: `ctx.sessions`
857
939
 
858
940
  ```ruby
@@ -1003,7 +1085,9 @@ CHANGELOG line and bumps the shipped bundles that use it. Anything else under
1003
1085
  `Samagotchi::Plugin::ToolResult`, `Samagotchi::Plugin::ModelError` (and its
1004
1086
  subclass `Samagotchi::Plugin::ModelCancelled`),
1005
1087
  `Samagotchi::Plugin::Service::Stopped`, `Samagotchi::Plugin::Sessions::Error`
1006
- and `Samagotchi::Plugin::AttachedContext::Error`.
1088
+ and `Samagotchi::Plugin::AttachedContext::Error`. `ctx.http` and its
1089
+ `Samagotchi::Plugin::HttpError` (with the subclass `HttpCancelled`) and
1090
+ `Samagotchi::Plugin::Http::Response` are **experimental until 1.0**, like the test kit.
1007
1091
  2. `Samagotchi::Plugin::Peer` as a base class. A subclass defines `post`,
1008
1092
  `check_hint!`, `paired_lines`, `transport_line`, `peer_dir` and
1009
1093
  `setup_paragraph` (optionally `skill_substitutions`), and sets `NAME`,
@@ -1020,6 +1104,12 @@ CHANGELOG line and bumps the shipped bundles that use it. Anything else under
1020
1104
  `Samagotchi::Log.debug`, `Samagotchi::Config.get` and
1021
1105
  `Samagotchi::VERSION`.
1022
1106
 
1107
+ `Samagotchi::Testing::Boundary::ALLOWED` is the checked copy of this list,
1108
+ and `Boundary::EXCEPTIONS` the per-bundle list of what each shipped bundle
1109
+ (and its specs) reaches beyond it, each with why: the APIs to design before
1110
+ that bundle could leave chi's repo. `spec/samagotchi/testing/boundary_spec.rb`
1111
+ fails on a reference in neither (docs/testing.md).
1112
+
1023
1113
  **Used by shipped bundles today, but internal** (may change without notice; a
1024
1114
  bundle that needs one should ask for an API instead):
1025
1115
 
@@ -1032,6 +1122,62 @@ bundle that needs one should ask for an API instead):
1032
1122
  `Samagotchi::Guardrails::GitInfo`.
1033
1123
  - `Samagotchi::Tools::Execute.const_get` reflection.
1034
1124
 
1125
+ ## Testing a bundle (experimental)
1126
+
1127
+ `samagotchi/testing` loads a bundle the way chi does and records what it does at chi's edges, so a test
1128
+ needs no fake `chi` or `ctx`. It is **experimental**: it may change in any release before 1.0.0 and is not
1129
+ part of "Stable for bundles".
1130
+
1131
+ ```ruby
1132
+ require "samagotchi/testing"
1133
+
1134
+ bundle = Samagotchi::Testing::Bundle.load("path/to/my-bundle", settings: { "every" => 3 },
1135
+ host: { session_id: "s1", cwd: Dir.pwd })
1136
+ bundle.fire(:after_turn, status: "completed") # → the event, after the hooks
1137
+ bundle.command("/mine stop") # → what the command returned
1138
+ bundle.tool("my_tool", path: "a.txt") # → the text the model reads
1139
+ verdict = bundle.tool_call({ name: "execute", content: "rm -rf /" })
1140
+ verdict.deny? # a real Guardrails::Verdict (votes: verdict.votes)
1141
+ bundle.notices.map(&:text) # also cards, steers, stops, generation_stops, questions
1142
+ bundle.answer({ selected: "yes" }) # the next ask_user's answer
1143
+ bundle.model_answers << "a summary" # the next ask_model's answer
1144
+ bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripted and recorded
1145
+ bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe" }) # ctx.http, never the network
1146
+ bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
1147
+ bundle.shutdown
1148
+ ```
1149
+
1150
+ - **Real seams.** The plugin loads through `Plugin::Loader.register_file` (the real `chi` API and a real `ctx`),
1151
+ the hook files through `Hooks::BundleLoader.load_hook_file`, into registries that already hold chi's own
1152
+ commands and tools: a name chi has fails the load as it would in chi. `tool_call` runs a real
1153
+ `Guardrails::Gate` (context and targets computed for the call; `verdict:` starts from one with a vote in it).
1154
+ Only the edges are recorded: the session (`Testing::Host`), the hook helpers (`Testing::Runtime`),
1155
+ `ctx.sessions`, `chi.init` tasks (`bundle.run_inits`) and `chi.replace_tools` sets
1156
+ (`bundle.apply_staged_tools!`). No sha256 or `requires_chi` check: it loads the source as it is.
1157
+ - **More:** `bundle.plugin` (the instance `register` ran on, for its own methods), `bundle.declarations` (the
1158
+ `chi.*` calls `register` made, in order, as made), `bundle.namespace`, `bundle.hook_names(event)`,
1159
+ `bundle.run_inits`, `sandbox.write_memory(name, text, scope:)` / `memory_dir`, `sandbox.fake_executable(name,
1160
+ body)` (first on `PATH`), `wait_until { }`.
1161
+ - **`ctx.http` is faked.** `bundle.http.stub(method, url_or_regexp, status: 200, headers: {}, body: nil, json: nil,
1162
+ error: nil) { |request| … }` answers it: a String URL matches exactly (query included), a Regexp the full URL, later
1163
+ stubs win, and a stub answers every matching call (a block that counts makes a sequence). `error: :refused` or
1164
+ `:timeout` raises the `HttpError` the real client would; `bundle.http.requests` lists what the plugin sent
1165
+ (`method`, `url`, `headers`, `body`, `json`) and `bundle.http.unmatched` what no stub answered. A request no stub
1166
+ answered raises `Samagotchi::Testing::UnstubbedRequest` (not an `HttpError`, but a plugin may rescue it anyway) and
1167
+ **fails the test at teardown** (RSpec and Minitest adapters: after the cleanup, and only a test that passed), so a
1168
+ spec can't reach the network. With plain `Bundle.load` there is no teardown: check `bundle.http.unmatched`.
1169
+ `bundle.set(cancelled: true)` makes a call without `cancel:` raise `HttpCancelled`.
1170
+ - **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
1171
+ go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
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
1174
+ `SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml.
1175
+ - **RSpec:** `require "samagotchi/testing/rspec"`; example groups tagged `:bundle` (a spec under a bundle's own
1176
+ `spec/` is tagged by itself) get `load_bundle(dir)`, `shipped_bundle(name)` and `sandbox`, each example in its
1177
+ own sandbox, its bundles shut down after it (chi's shipped bundles keep theirs in `<bundle>/spec/`,
1178
+ docs/testing.md). **Minitest:** `require "samagotchi/testing/minitest"` and subclass
1179
+ `Samagotchi::Testing::Case` (the same helpers).
1180
+
1035
1181
  ## The btw bundle
1036
1182
 
1037
1183
  `chi bundle install btw` (or the `dev` profile) installs the bundle shipped with chi. It is written
@@ -1097,7 +1243,7 @@ bundles:
1097
1243
  and values: only the digest is stored) and `cwd`. A session with a saved
1098
1244
  list can search its tools at once and **doesn't start the server**: the
1099
1245
  first call of one of its tools does (the call's row shows the wait). So a
1100
- session that never uses MCP spawns nothing, and a new chat opens without
1246
+ session that never uses MCP spawns nothing, and a new session opens without
1101
1247
  waiting for `npx`. If the live list differs from the saved one, the saved
1102
1248
  one is replaced, and so is what a search finds. The model's tools never
1103
1249
  change, so the prompt cache keeps. The saved file also keeps the first
@@ -1127,7 +1273,7 @@ bundles:
1127
1273
  you want at once.
1128
1274
  - **`npx -y …@latest` checks the npm registry on every start** (~3.4 s for
1129
1275
  `chrome-devtools-mcp`, against ~0.9 s for the installed binary). The cache
1130
- hides it from new chats, but not from the first call. For a faster first
1276
+ hides it from new sessions, but not from the first call. For a faster first
1131
1277
  call, install the server once and run it directly:
1132
1278
 
1133
1279
  ```yaml