samagotchi 0.67.0 → 0.69.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +73 -17
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +8 -2
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/effective_model.rb +283 -0
  20. data/lib/samagotchi/engine.rb +216 -360
  21. data/lib/samagotchi/guardrails/targets.rb +9 -2
  22. data/lib/samagotchi/host_config.rb +1 -29
  23. data/lib/samagotchi/host_model.rb +0 -7
  24. data/lib/samagotchi/kernel_loop.rb +24 -7
  25. data/lib/samagotchi/level_setting_command.rb +99 -0
  26. data/lib/samagotchi/llm/errors.rb +47 -1
  27. data/lib/samagotchi/llm/http.rb +20 -2
  28. data/lib/samagotchi/llm_context_stale.rb +6 -0
  29. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  30. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  31. data/lib/samagotchi/model_price.rb +2 -4
  32. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  33. data/lib/samagotchi/session.rb +22 -8
  34. data/lib/samagotchi/session_metrics.rb +4 -1
  35. data/lib/samagotchi/session_setting.rb +86 -0
  36. data/lib/samagotchi/system_prompt.rb +19 -3
  37. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  38. data/lib/samagotchi/thinking_command.rb +24 -76
  39. data/lib/samagotchi/thinking_notices.rb +169 -0
  40. data/lib/samagotchi/tool_activity.rb +3 -0
  41. data/lib/samagotchi/tool_row_fields.rb +4 -2
  42. data/lib/samagotchi/tool_runner.rb +24 -3
  43. data/lib/samagotchi/toolkit.rb +27 -14
  44. data/lib/samagotchi/toolkit_binding.rb +8 -2
  45. data/lib/samagotchi/toolkit_command.rb +20 -4
  46. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  47. data/lib/samagotchi/tools/registry.rb +95 -8
  48. data/lib/samagotchi/tools/tool_index.rb +223 -72
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +69 -93
  51. data/lib/samagotchi/web/message_parts.rb +5 -2
  52. data/lib/samagotchi/web/public/activity.js +3 -1
  53. data/lib/samagotchi/web/public/app.js +196 -526
  54. data/lib/samagotchi/web/public/bubbles.js +291 -0
  55. data/lib/samagotchi/web/public/cards_place.js +184 -0
  56. data/lib/samagotchi/web/public/chains.js +19 -2
  57. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  58. data/lib/samagotchi/web/public/command_list.js +12 -10
  59. data/lib/samagotchi/web/public/families_view.js +15 -0
  60. data/lib/samagotchi/web/public/index.html +21 -11
  61. data/lib/samagotchi/web/public/info_bar.js +3 -3
  62. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  63. data/lib/samagotchi/web/public/init_row.js +21 -10
  64. data/lib/samagotchi/web/public/level_chip.js +29 -80
  65. data/lib/samagotchi/web/public/lists_view.js +64 -9
  66. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  67. data/lib/samagotchi/web/public/model_picker.js +47 -28
  68. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  69. data/lib/samagotchi/web/public/refs.js +13 -0
  70. data/lib/samagotchi/web/public/session_state.js +5 -0
  71. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  72. data/lib/samagotchi/web/public/stage_model.js +33 -0
  73. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  74. data/lib/samagotchi/web/public/stage_view.js +56 -335
  75. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  77. data/lib/samagotchi/web/public/timing.js +3 -1
  78. data/lib/samagotchi/web/public/toast_place.js +16 -0
  79. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  80. data/lib/samagotchi/web/public/turn_html.js +4 -2
  81. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  82. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  83. data/lib/samagotchi/worker.rb +11 -16
  84. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58f42be965113cdd1f5736f77b9a5be8e9fbd77088b4a2013f9b7e86ae7ceb12
4
- data.tar.gz: a093f0921d62e84c9e6c34ac10482e57e9e041a5a9d21eda41a88052e819cda1
3
+ metadata.gz: 5ef7820f894e85b932288a38a22e1f312e01b5f4f1d100996acde11443baab19
4
+ data.tar.gz: 44f15fe12934aa7c56bff657e8b93a1d0165a752bcf0715f71cb04bb17ed7ac6
5
5
  SHA512:
6
- metadata.gz: d02e8644b724765182cabb9a74eafcfaec94eb13781680e29cc33bfb1ee2bc850f55a7df4117b61d8a2889c7d48bf4792b88ba316676a1fb6be5632cc98d1a3f
7
- data.tar.gz: a4397b9a00729a4eb230415e265c834f85f42b8b6ded242dc3c981ad6f3e543f367d7a9df00ed6d516aef12c2e2266a765b0f66c7025f575975c83fd4b556b86
6
+ metadata.gz: 38d2aebcc7f777a40ceb1f0008c0160fd374541ff97b74b44ba0d61611cf7e2a103a4398571327b26cd7ffa80d564a552166d74ecfca172788483b1b43d166c7
7
+ data.tar.gz: 64a08c7808f2a9d58cbd5135ebadef7f59157876d738c16a3b6793c9b424f8a6dca90454ea961672ce0e14a71f809026ac49ba51d1db0b1ff4389073a06fbd4e
data/CHANGELOG.md CHANGED
@@ -8,6 +8,65 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.69.0] - 2026-10-14
12
+
13
+ ### Added
14
+
15
+ - Web: a new chain link (continue →) shows the model picker in its composer until its first turn, so the next link can
16
+ start on another model or provider; the continue toast shows the fallback warning when the previous model's host is
17
+ gone.
18
+ - Built-ins a session's toolkit leaves out are reachable: `find_tools` searches them (group `chi`) beside MCP
19
+ servers' tools, and `call_tool` runs them through the usual guardrails as the real call. `remove:` /
20
+ `--no-tool` / `/toolkit remove` stay hard.
21
+
22
+ ### Changed
23
+
24
+ - find_tools' description and its `server` parameter (now `group`) changed: sessions with MCP servers read their
25
+ prompt again once. So do sessions on `lean`, `child`, `coordinator` or a kit based on them: they now declare
26
+ `find_tools` and `call_tool`, and their not-loaded line changed.
27
+ - A toolkit based on `lean` or `child` (a `toolkit.kits` entry, a bundle's `chi.toolkit`) no longer keeps the
28
+ built-ins its base leaves out from the model: they're reachable through `find_tools` / `call_tool`, guarded as
29
+ themselves. Name one under `remove:` to keep it out.
30
+ - Guardrail rules on `call_tool` no longer see calls of chi's own tools made through it; they match the tool's own
31
+ name.
32
+ - Shipped bundles: a change that needs a newer chi marks the manifest `requires_chi_next` instead of raising
33
+ `requires_chi`; `rake release:bump` raises it to the released version and `rake release:check` fails on a marker
34
+ left over.
35
+ - `/toolkit remove X` on a tool the toolkit leaves out is saved (it makes X unreachable). `/toolkit add X` then
36
+ `/toolkit remove X` now removes X (it used to go back to the kit's default), and a remove then an add declares X.
37
+ - The `child` toolkit also removes `delegate` and `delegate_result`: a child on it can't start children, even
38
+ through call_tool.
39
+
40
+ ### Fixed
41
+
42
+ - A TLS handshake blip on a gateway (`SSL_connect … unexpected eof`, a reset mid-handshake) is retried like other
43
+ network errors instead of failing the turn at once; a certificate chi doesn't trust, a certificate for another
44
+ name, or an `https://` base_url on a plain HTTP port fails at once, saying what to check (`SSL_CERT_FILE`, the
45
+ host's base_url).
46
+ - A bundle tool named like an MCP tool (`mcp_fs_read`) no longer takes `call_tool` calls of that MCP tool out of
47
+ `mcp_*` rules.
48
+ - Web: on a phone the start page's model, thinking and LLM context chips wrap instead of being cut short; a toast no
49
+ longer covers a fresh link's open model panel; the family popover's status dots keep pulsing in step.
50
+
51
+ ## [0.68.0] - 2026-10-14
52
+
53
+ ### Changed
54
+
55
+ - Web: the settings popovers' selects (thinking level, LLM context strategy and apply) look the same in Safari, Chrome
56
+ and Firefox: chi's own box and caret instead of each browser's native control.
57
+ - Web: the thinking chip comes before the LLM context chip, on the session bar (think, reasoning, llm ctx, tools) and
58
+ beside the start page's model picker.
59
+
60
+ ### Fixed
61
+
62
+ - `/model` while the web or another terminal read `/stats` no longer leaves the new model on the old model's prompt
63
+ profile (its turns formatted for the wrong family until the next `/model`).
64
+ - Web: the sessions strip no longer blinks when a session updates: a card fades in only when it is new to the list,
65
+ and a running or waiting card's dot keeps its pulse through the redraw. All sessions no longer re-fades on every
66
+ update or search keystroke either.
67
+
68
+ Update with `chi update` (the system bundle 0.68.0), then restart `chi web`.
69
+
11
70
  ## [0.67.0] - 2026-10-14
12
71
 
13
72
  ### Added
@@ -2937,7 +2996,9 @@ and long-lived sessions.
2937
2996
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2938
2997
  hotkey panel that send selected text or the clipboard to your sessions.
2939
2998
 
2940
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.67.0...HEAD
2999
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.69.0...HEAD
3000
+ [0.69.0]: https://github.com/dm1try/samagotchi/compare/v0.68.0...v0.69.0
3001
+ [0.68.0]: https://github.com/dm1try/samagotchi/compare/v0.67.0...v0.68.0
2941
3002
  [0.67.0]: https://github.com/dm1try/samagotchi/compare/v0.66.0...v0.67.0
2942
3003
  [0.66.0]: https://github.com/dm1try/samagotchi/compare/v0.65.0...v0.66.0
2943
3004
  [0.65.0]: https://github.com/dm1try/samagotchi/compare/v0.64.0...v0.65.0
data/docs/architecture.md CHANGED
@@ -400,7 +400,9 @@ by one pure resolver, `ModelRef.parse` (host prefix, alias), given the configure
400
400
  hosts and aliases; `HostRegistry` routes a name that names no host (its model
401
401
  index after `/models`) and builds the `ModelTarget`.
402
402
 
403
- Engine picks the loop from the effective model's host (`HostRegistry#resolve`):
403
+ Engine picks the loop from the effective model's host: `EffectiveModel.of` resolves it (`HostRegistry#resolve`)
404
+ on every read, never kept, since config.yml and a model listing can move it without a `/model`
405
+ ([internals/engine.md](internals/engine.md), "EffectiveModel"):
404
406
 
405
407
  | Loop | Class | Host | Talks through |
406
408
  |---|---|---|---|
@@ -450,7 +452,7 @@ message (docs/internals/context-telemetry.md).
450
452
  `HostRegistry#adapter_for`, built by `LLM::Adapters` from the host's api) share
451
453
  `LLM::HTTP`: timeouts, TLS for https, a line reader for streamed bodies, the retry loop (`retry.*`; network errors, 429 and
452
454
  500/502/503/504/529, honouring `Retry-After`; never after a stream has produced
453
- output) and cancel. Cancel closes the in-flight socket from the
455
+ output; a refused connection and a TLS handshake that can't work, `LLM::TLSError`, fail at once) and cancel. Cancel closes the in-flight socket from the
454
456
  `CancellationController` listener, so it works on any thread.
455
457
 
456
458
  `LLM::AnthropicMessages` talks Anthropic's Messages API (`POST {root}/v1/messages`) for the same chat loop:
@@ -496,7 +498,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
496
498
 
497
499
  | Component | Main file(s) | What it is |
498
500
  |---|---|---|
499
- | Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns, exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). |
501
+ | Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns, exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
500
502
  | Config | `config.rb` | Every setting as an `Entry`: env, config.yml and CLI layers ([configuration.md](configuration.md)). |
501
503
  | MemoryBundle | `memory_bundle.rb`, `memory_bundle/{installer,builder,installed_bundle,plugin_ref,bundle_hook}.rb` (`Installer`, `Builder`, `InstalledBundle`, `PluginRef`, `BundleHook`) | Installing, building and reading bundles of memories, hooks and plugins. |
502
504
  | Plugin API | `plugin/api.rb` (`Plugin::Api`), `plugin/tool_spec.rb` (`Plugin::ToolSpec`: a checked tool declaration, how it is registered), `plugin/loader.rb` | What a bundle's plugin registers: tools, commands, annotation actions, hooks, services, toolkits, peer sources ([plugins.md](plugins.md)). |
@@ -515,7 +517,8 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
515
517
  | 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; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
516
518
  | Steer delivery | `steer.rb` (`Steer`), `delivery.rb` (`Delivery`), `steer_cut.rb` (`SteerCut`), `generation_phase.rb`, `waiting_steer.rb`, `steer_cut_after.rb` (`SteerCutAfter`), `cut_policy.rb` (`CutPolicy`) | A message for a running turn: at the next boundary, cutting a thinking-only generation, or queued for the turn's end ([internals/engine.md](internals/engine.md)). |
517
519
  | Prompt files | `prompt_files.rb` (`PromptFiles`) | The AGENTS.md chain (root → cwd) and the appended files the system prompt carries. |
518
- | Model settings | `model_profile.rb` (`ModelProfile`), `model_settings.rb`, `sampling_settings.rb`, `model_catalog.rb` (`ModelCatalog`), `model_notes.rb` (`ModelNotes`) | A model's prompt format, its `models:` entry, its request parameters, the pickable model rows (`chi models`, `/api/models`), the notes its prompt loads. |
520
+ | Model settings | `effective_model.rb` (`EffectiveModel`, `ModelChoice`), `model_profile.rb` (`ModelProfile`), `model_settings.rb`, `sampling_settings.rb`, `model_catalog.rb` (`ModelCatalog`), `model_notes.rb` (`ModelNotes`) | The effective model's settings as one snapshot per read, a model's prompt format, its `models:` entry, its request parameters, the pickable model rows (`chi models`, `/api/models`), the notes its prompt loads. |
521
+ | Session settings | `session_setting.rb` (`SessionSetting`), `level_setting_command.rb` (`/thinking`, `/reasoning-history`), `llm_context_command.rb`, `toolkit_command.rb`, `thinking_notices.rb` (`ThinkingNotices`) | A session's own llm_context, thinking, reasoning_history and toolkit: their chips (session state, `/stats`, `:command_ran`, the web), the commands that set them, the thinking level's notices. |
519
522
  | Dialects | `llm/dialect.rb` (`LLM::Dialect`) | What an `api: anthropic` provider's `/v1/messages` does differently (`dialect: fireworks`) ([internals/anthropic.md](internals/anthropic.md)). |
520
523
  | Logging | `log.rb` (`Log`), `log_subscriber.rb` (`LogSubscriber`), `log_path.rb` (`LogPath`), `log_line.rb` | The debug log: tagged records, a session's event trail, where the file goes ([configuration.md](configuration.md#debug-log-file)). |
521
524
  | Stores | `reminder_store.rb` (`ReminderStore`), `recap_store.rb` (`RecapStore`) | A session's reminders, and its idle recap (`recap.json`). |
data/docs/cli.md CHANGED
@@ -517,7 +517,12 @@ line, the web as the `reasoning` chip. See "Reasoning history" in
517
517
  [configuration.md](configuration.md).
518
518
 
519
519
  `/toolkit` lists the tools the next turn declares (each with its estimated
520
- tokens), the session's toolkit and the tools it leaves out. `/toolkit NAME` puts
520
+ tokens), the session's toolkit and the tools it leaves out: `reachable
521
+ (find_tools): …` (built-ins the model reaches with find_tools and call_tool)
522
+ and `removed: …` (only `/toolkit add` brings one back), or `not loaded: …` when
523
+ the toolkit removes call_tool. `/toolkit remove TOOL` is hard: a tool the
524
+ toolkit left out reachable becomes unreachable (saved, and asked first once the
525
+ session has a model reply), also after an add. `/toolkit NAME` puts
521
526
  the session on another toolkit (dropping its own adds and removes); `/toolkit
522
527
  add TOOL` and `/toolkit remove TOOL` change one tool (a tool's name keeps its
523
528
  case). The change is saved at once and applies from the next turn's start; a
@@ -988,8 +988,8 @@ Transient network failures are retried automatically with exponential backoff.
988
988
 
989
989
  - Default retries: `5` (up to `6` total attempts including the first call).
990
990
  - Default backoff: `0.5s`, `1s`, `2s`, `4s`, `8s`.
991
- - Retry scope: transient network errors (timeouts, reset connections, EOF/socket reachability failures),
992
- HTTP 429 and HTTP 500/502/503/504/529. A `Retry-After` header replaces the backoff delay; one longer than
991
+ - Retry scope: transient network errors (timeouts, reset connections, broken pipes, EOF/socket reachability
992
+ failures, a TLS handshake that drops: `SSL_connect … unexpected eof while reading`), HTTP 429 and HTTP 500/502/503/504/529. A `Retry-After` header replaces the backoff delay; one longer than
993
993
  60s is not waited out and the error is reported instead.
994
994
  - An HTTP 402 about credit held by in-flight requests (OpenRouter reserves credit for each running request, so
995
995
  one session's long request can make another's fail) is retried like a 429, 20s apart unless `Retry-After`
@@ -998,6 +998,11 @@ Transient network failures are retried automatically with exponential backoff.
998
998
  budget, so it fails at once (lower `default.max_tokens` or raise the key's limit).
999
999
  - A refused connection (nothing listening) is not retried: the turn fails at once with `can't reach host <name> at
1000
1000
  <address> (connection refused) — is the server running?`.
1001
+ - Nor is a TLS handshake that can't work, which fails at once saying what to check: a certificate chi doesn't
1002
+ trust (`certificate verify failed (self-signed certificate)`, `unable to get local issuer certificate`, an expired
1003
+ one: set `SSL_CERT_FILE` to a CA bundle that has the gateway's CA), a certificate for another name (`hostname
1004
+ mismatch`: check the host's `base_url`), or a server that doesn't speak TLS there (`wrong version number`: an
1005
+ `https://` base_url on a plain HTTP port; should it be `http://`?).
1001
1006
  - A stream that has already produced output is never retried at the transport (the retry would repeat it). The
1002
1007
  chat loop (an OpenAI-compatible host) drops the partial reply and asks again for the whole step, from the same
1003
1008
  conversation, at most twice (at most `retry.max` times when that is smaller: `retry.max: 0` turns it off), after
@@ -1331,7 +1336,7 @@ toolkit:
1331
1336
  |---|---|
1332
1337
  | `full` | every built-in; the default |
1333
1338
  | `lean` | `execute`, `read`, `write`, `edit`, `memory_read`, `memory_write`, `task_create`, `task_wait`, `task_get`, `task_list`, `task_stop`, `ask_user_question`, `web_fetch`, `context_read` |
1334
- | `child` | `lean` plus `send_note`, without `memory_write` |
1339
+ | `child` | `lean` plus `send_note`, without `memory_write`, `delegate`, `delegate_result` |
1335
1340
  | `coordinator` | `lean` plus `delegate`, `delegate_result`, `list_sessions` (declared by the coordinator bundle) |
1336
1341
 
1337
1342
  - **Plugin tools** are declared in every toolkit unless its `remove:` names them, and so is `forget_outputs` under
@@ -1342,9 +1347,26 @@ toolkit:
1342
1347
  for a delegate child, or `chi --toolkit NAME` / `--tool` / `--no-tool` ([cli](cli.md)). Changing `toolkit.default`
1343
1348
  doesn't move existing sessions, not even when their worker is woken again; a session from before toolkits is on
1344
1349
  `full`. A continue and a fork keep the toolkit. `/toolkit` shows and changes it.
1345
- - **Not loaded.** When a toolkit leaves tools out, one line after the base prompt names them (`Not loaded in this
1346
- session (toolkit lean): …. Only the user can load one (/toolkit add <name>).`). A call to one is refused before
1347
- the guardrails (no approval card): `Error: delegate isn't loaded in this session (toolkit lean) …`.
1350
+ - **Left out: reachable or removed.** A built-in missing from a kit's base list (`lean` has no `delegate`; a
1351
+ `kits:` entry on `base: lean` or `child` inherits that) is left out softly: it is **reachable**. `find_tools` searches it as group `chi` (`chi/delegate`, with its inputSchema) beside
1352
+ the MCP servers' tools, and `call_tool(tool: "chi/delegate", args: {…})` runs it as `delegate` itself, through the
1353
+ usual guardrails, approvals and hooks ([plugins](plugins.md#chitool_sourcecall-acts_as-label-wait-prefix)). A
1354
+ tool in a toolkit's `remove:`, a `--no-tool` or a `/toolkit remove` is **removed**: hard, also after an add, and
1355
+ call_tool can't reach it (`child` removes `memory_write`, `delegate` and `delegate_result`, so a child on it
1356
+ starts no children). To keep a built-in away from the model, name it under `remove:`. `find_tools` and `call_tool`
1357
+ are declared when a toolkit has a reachable built-in (or an MCP server is configured); `full` with no MCP server
1358
+ declares neither.
1359
+ - **The prompt line.** One line after the base prompt names them: `Not declared in this session (toolkit lean): ….
1360
+ Search them with find_tools; call one with call_tool.`, then `Removed (toolkit child): memory_write, …. Only the
1361
+ user can load one (/toolkit add <name>).` With `find_tools` removed, the first sentence ends `Call one with
1362
+ call_tool(tool: "chi/<name>", args: {…}).` instead. With `call_tool` itself removed, nothing is reachable and one
1363
+ line names them all: `Not loaded in this session (toolkit lean): …. Only the user can load one (/toolkit add
1364
+ <name>).`
1365
+ - **Calls by name.** A direct call of a reachable tool is refused before the guardrails (no approval card), with
1366
+ the route: `Error: delegate isn't declared in this session (toolkit lean); call it with call_tool(tool:
1367
+ "chi/delegate", args: {…}) (find_tools gives its inputSchema).` A removed tool's: `Error: memory_write isn't
1368
+ loaded in this session (toolkit child) and can't be loaded from here; only the user can (/toolkit add
1369
+ memory_write).` `/toolkit` lists both kinds, and the web's toolkit chip says them in its tooltip.
1348
1370
  - **Applied at a turn's start.** A change (`/toolkit`, a plugin's `ctx.use_toolkit`) is saved at once and the next
1349
1371
  turn declares it; once the session has a model reply, `/toolkit` asks first ("Changes that restart the prompt
1350
1372
  cache").
data/docs/guardrails.md CHANGED
@@ -303,7 +303,7 @@ A plugin tool that runs other tools for the model (a dispatcher such as
303
303
  and with which arguments (`args:`). A rule's `tool:` then matches the call's
304
304
  own name **or** the tool it acts as: `tool: mcp_github_*` fires for a
305
305
  `call_tool` that acts as `mcp_github_merge_pull_request`, and `tool: call_tool`
306
- fires whatever it acts as. The question names the tool it acts as (by the
306
+ fires whatever MCP tool it acts as. The question names the tool it acts as (by the
307
307
  `label:` its `targets:` gives, if any: display only) and shows its arguments, and an approval of "this call" is keyed by the real name, the
308
308
  alias and those arguments (`/guardrails` lists it as
309
309
  `call_tool>mcp_github_merge_pull_request:number=7 …`), so it never covers a
@@ -313,6 +313,28 @@ one of chi's own tools or another bundle's tool: that `acts_as:` is dropped
313
313
  name only. Hooks and loop-guard's `ignore_tools` still see the call's own
314
314
  name.
315
315
 
316
+ chi's `call_tool` is exempt from the "another bundle's tool" drop (not the
317
+ core-tool one): its `acts_as` is always a tool its sources published, so a
318
+ bundle tool that happens to share an MCP tool's `mcp_<server>_<tool>` name
319
+ can't take `call_tool` calls of that MCP tool out of your `mcp_*` rules. The
320
+ trade-off, when such a clash exists:
321
+ - an `allow` or `ask` rule by that name matches both the bundle tool and the
322
+ MCP call;
323
+ - a `rule`-scope approval ("always for this rule") covers both, because it
324
+ matches by rule, source and place, not by tool;
325
+ - `session` and `repo` approvals stay distinct (`call_tool>mcp_x:…` vs
326
+ `mcp_x:…`);
327
+ - deny rules cover both, which is the point.
328
+
329
+ A `call_tool` of one of chi's own tools (`chi/delegate`: a built-in the
330
+ session's toolkit leaves out, [toolkits](configuration.md#toolkits)) is
331
+ gated **as that tool**: it is turned into the `delegate` call before the
332
+ guardrails run, so every rule on `delegate` (or `execute`'s `command:`, the
333
+ `rm` check, a delegate child's boundary) gives the same verdict as for a
334
+ direct call, and an approval is keyed `delegate:…` either way. `tool:
335
+ call_tool` rules no longer see such calls, only MCP calls and misses. A
336
+ `chi/` name the toolkit removes is refused before the gate, with no card.
337
+
316
338
  ```yaml
317
339
  - id: mcp-ask
318
340
  # every MCP call (call_tool, acting as mcp_<server>_<tool>); not a find_tools search.
data/docs/hooks.md CHANGED
@@ -536,6 +536,10 @@ A `write`'s `content:` is the file text; an `edit` carries `old_text:` and
536
536
  arguments whole on `args:` (string keys) instead, and its `content:` is those
537
537
  arguments as JSON. An MCP tool's call is chi's `call_tool` (its tool index's, which the mcp
538
538
  bundle runs): `tool: "call_tool"`, with `args: {"tool" => "<server>/<tool>", "args" => {…}}`.
539
+ A built-in reached through `call_tool` (`chi/delegate`, a tool the session's toolkit doesn't
540
+ declare) fires as that built-in, its call built as a direct call's, with `call[:via] == "call_tool"`.
541
+ A hook that drops `via:` or rewrites the call to a tool the toolkit removes gets the call refused
542
+ by its name.
539
543
 
540
544
  ## Bundle Hooks (unified workflow bundle)
541
545
 
@@ -24,7 +24,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
24
24
  |---|---|
25
25
  | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash`; runs the Installer, prints its summary, runs the conflict agent step |
26
26
  | `SourceNormalizer` (source.rb) | source → local dir; a git/zip/tar extract is *owned* and removed after the install |
27
- | `Manifest` | parses/validates `manifest.yml` (files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers) and writes it (`Manifest.write`, for Builder) |
27
+ | `Manifest` | parses/validates `manifest.yml` (files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
28
28
  | `Installer` | one install/upgrade/dry run of one bundle (below) |
29
29
  | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
30
30
  | `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
@@ -34,20 +34,23 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
34
34
  │ # resumed: true (only on a step-limit Continue: resumed: given),
35
35
  │ # images (only when there are any), cut_after (the model's cut wait, s)}
36
36
  │ # refresh_profile! (may ask the server's /props once)
37
- │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
37
+ │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate),
38
+ │ # from one EffectiveModel; ThinkingNotices#turn_started keeps the level and says one the host can't do
38
39
  │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
39
- │ # then the session's toolkit (apply_toolkit!: Registry#toolkit, a prompt rebuild when it changed)
40
+ │ # then the session's toolkit (apply_toolkit!: Registry#toolkit, ToolIndex#refresh! for find_tools'
41
+ │ # chi line, a prompt rebuild when it changed)
40
42
  │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
41
43
  │ # (a frozen copy of the history, the prompt, and resumed)
42
44
  │ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
43
45
  │ # Returns false when a Stop came during the probes or the init wait, else true
44
46
  │
45
47
  ├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
46
- │ # sync_kernel_client!, then @kernel.turn_settings = turn.settings with model_name, window_setting,
47
- │ # llm_context (turn_llm_context: the session's, the model's/host's, else llm_context.strategy)
48
- │ # and price (turn_price: hosts.<name>.models.<id>.price), all read per turn
48
+ │ # one EffectiveModel (after the hooks, which may switch the model): sync_kernel_client!, then
49
+ │ # @kernel.turn_settings = turn.settings with model_name, window_setting, llm_context (the
50
+ │ # session's, the model's/host's, else llm_context.strategy) and price
51
+ │ # (hosts.<name>.models.<id>.price), all read per turn
49
52
  │ # turn.limit = max_iterations or IterationLimit.for
50
- │ # backend.complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
53
+ │ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
51
54
  │ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
52
55
  │ # context_status, empty_steps, empty_retries)
53
56
  │
@@ -118,6 +121,16 @@ A worker's TERM, HUP or INT (WorkerSignals, trapped by a spawned worker only):
118
121
  loop left brings only that hard exit, and run_session_loop restores the default handlers
119
122
  → run_session_loop: exit 0, no discard; input queued behind the stopped turn wakes a new worker
120
123
 
124
+ The effective model doesn't resolve (a broken host config, HostRegistry#resolve raising):
125
+ → the rescued readers give their fallback (Engine#model_setting) and log effective_model_unresolved;
126
+ a setting that fails on a resolved model gives its fallback and logs <setting>_unresolved
127
+ (sampling, thinking, reasoning_history, steer_cut_after, llm_context, window, price, served,
128
+ vision_adapter); served_probe_failed when asking the server for its served model fails;
129
+ alias_note_failed, thinking_notice_failed likewise
130
+ → WARN once per process for an (event, model, error class), DEBUG after (EffectiveModel::Unresolved)
131
+ → session_state_snapshot never raises (its model fields are nil); a turn, stats_snapshot,
132
+ chat_model? and system_prompt raise, as before
133
+
121
134
  Stopped before the model was asked (prepare_turn returned false):
122
135
  → stopped_before_model(turn) → end_turn(turn, "canceled")
123
136
  → nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
@@ -129,7 +142,7 @@ Stopped before the model was asked (prepare_turn returned false):
129
142
  The Engine does not talk to models directly. `Engine#backend` picks the loop the effective model's host speaks:
130
143
 
131
144
  ```
132
- Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
145
+ Engine#backend → backend_for(effective_model) # EffectiveModel.of(@host_registry, @model_choice)
133
146
  → host entry chat? (an api in LLM::Adapters::BY_API: openai, anthropic): LLM::ChatLoop (one per Engine, built on first use)
134
147
  → else: LLM::NativeBackend wrapping the Engine's KernelLoop
135
148
  ```
@@ -243,8 +256,11 @@ One iteration with tool calls:
243
256
  → :tool_dispatch_started {iteration, call_count}
244
257
  → for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
245
258
  on_stream_event:, max_tool_output_chars:)
246
- → a tool the session's toolkit leaves out (Registry#left_out) skips the gate: no hooks, no card;
247
- KernelLoop#dispatch answers it with the not-loaded error
259
+ → first, the call's entry unwraps it (Registry::Entry#unwrap: a call_tool of chi/<built-in> becomes
260
+ that built-in's call with via: "call_tool", ToolIndex#unwrap); the rest sees the real call
261
+ → a tool the session's toolkit leaves out (Registry#left_out; with via:, one Registry#indexed doesn't
262
+ reach) skips the gate: no hooks, no card; KernelLoop#dispatch answers it (the call_tool route for a
263
+ reachable tool, the not-loaded error for a removed one)
248
264
  → Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
249
265
  → :tool_call_started
250
266
  → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
@@ -261,11 +277,13 @@ One iteration with tool calls:
261
277
  ```
262
278
 
263
279
  **ToolRunner** (`ToolRunner#run`):
280
+ - Unwraps a call_tool of a built-in first (`Registry::Entry#unwrap`), so `original`, the params and the label
281
+ are the built-in's and no "ran as:" line follows.
264
282
  - Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
265
283
  replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
266
284
  - Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
267
- title, called_as, view, ref, refs}. The row fields (title, called_as, view, ref) come from `ToolRowFields.for`, which the
268
- reload (`Web::MessageParts#tool_part`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as) travel
285
+ title, called_as, via, view, ref, refs}. The row fields (title, called_as, via, view, ref) come from `ToolRowFields.for`, which the
286
+ reload (`Web::MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as, via) travel
269
287
  inside `activity` on the completed event; `EVENT_KEYS` (view, ref, refs) go top-level on both events.
270
288
  `Bridge::TurnAccumulator` (snapshot and replay) and the web (`activity.js` `ACTIVITY_FIELDS` /
271
289
  `EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
@@ -326,6 +344,38 @@ TurnState
326
344
 
327
345
  The lock is a leaf: nothing is called out while it is held.
328
346
 
347
+ ### EffectiveModel (a snapshot per read)
348
+
349
+ `EffectiveModel.of(registry, choice)` resolves the model once (`HostRegistry#resolve`: host, client, the id
350
+ sent) with its lookup names, and reads each setting from them (sampling, thinking, reasoning history, steer cut,
351
+ LLM context, window, price, served model, vision, profile). It is built per read and never kept: config.yml is
352
+ read live, a model listing can move a bare id to another host, and another process's saved model lists change
353
+ what a resolve gives, all without a `/model`. Frozen, so any thread may hold one.
354
+
355
+ - The Engine's readers take one (`Engine#effective_model`); those whose fallback always covered the resolve go
356
+ through `#model_setting`. A turn takes one in `turn_settings` (before the hooks) and one in `generate` (after
357
+ them); `system_prompt`, `steer_cut_after` (at `:turn_started`) and the turn-end warm-up take their own;
358
+ `stats_snapshot` and `session_state_snapshot` take one each (the latter nil-tolerant).
359
+ - Never resolved (they hold on a broken resolve): `effective_model_ref`, `bare_model_name`,
360
+ `guardrail_model_name`, `sync_model_key!`, `served_model(probe: false)` (`HostRegistry#model_ref`/`#bare_name`).
361
+ - Readers on other threads: the Bridge (`/state`, `/stats`, the SSE reset, a steer's cut wait), the worker's
362
+ command thread, the recap thread and plugin threads (`session_model_recap_target`), the REPL.
363
+ - `ModelChoice` (`@model_choice`) holds the name and the typed name as one value, so a reader on another thread
364
+ never pairs one model's name with another's typed name.
365
+ - `EffectiveModel.of_session` builds a saved session's (no worker): `Web::App`, `LLMContextStrategy.for_session`.
366
+
367
+ `SessionSetting` names the four per-session settings (llm_context, thinking, reasoning_history, toolkit): the
368
+ session's own value, the commands that move each chip, its chip from an EffectiveModel or from the Engine's
369
+ readers, a saved session's chip, and how the web takes a worker snapshot's. The session state, `/stats` (no
370
+ toolkit), a `:command_ran` and the web's session JSON list them from it.
371
+
372
+ ### ThinkingNotices
373
+
374
+ What a turn's thinking level leads to, said once per session and host (`thinking_notices.rb`): an effort the
375
+ host can't do, off the model didn't honour, a host refusing the thinking fields or the reasoning sent back. It
376
+ keeps the turn's `[level, ModelTarget]` and whether the generation thought; `turn_settings` and the stream handler
377
+ call it. Turn thread only.
378
+
329
379
  ### Turn (Engine::Turn)
330
380
 
331
381
  The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
@@ -435,16 +485,17 @@ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook
435
485
  ```
436
486
  Engine initialization (in this order, among other things):
437
487
  @turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
438
- @host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
488
+ @host_registry, @model_choice (ModelChoice: the name and the name as typed); @client = effective_model.client
439
489
  @given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
440
490
  @hook_routes (HookRoutes: notices, hook questions, stop_turn/stop_generation, steer attribution)
441
491
  @guardrail_wiring, @question_desk
442
492
  @toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
443
493
  @extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
444
494
  @tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins),
445
- then @tool_index.declare(@tools): find_tools + call_tool (source "index", not core) when a plugin
446
- registered a chi.tool_source (Tools::ToolIndex: the index's groups, search, call checks; the source runs
447
- the call with its own plugin Context)
495
+ then (plugins or not) @tool_index.declare(@tools): find_tools + call_tool (source "index", not core),
496
+ declared per toolkit by Registry#declared? (a source registered at load, or a reachable built-in) and
497
+ sets call_tool's unwrap (Tools::ToolIndex: the chi group from Registry#reachable_names, the sources'
498
+ groups, search, call checks, the unwrap; a source runs the call with its own plugin Context)
448
499
  wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
449
500
  @kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
450
501
  @hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
@@ -453,8 +504,9 @@ Engine initialization (in this order, among other things):
453
504
  @session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
454
505
 
455
506
  Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
456
- → checks the host (ModelProfile.check_host!), sets @effective_model_name
457
- → drops the given profile and the profile resolution (resolved again on first need)
507
+ → checks the host (ModelProfile.check_host!), sets @model_choice (name and typed name in one assignment)
508
+ → drops the given profile and the profile resolution (resolved again on first need; one that another
509
+ thread finishes for the old model is neither kept nor applied: #store_profile)
458
510
  → sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
459
511
  → sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
460
512
  → the kernel is never rebuilt; the next turn's backend follows the new host's api
@@ -608,6 +660,10 @@ Paths are under `lib/samagotchi/`.
608
660
  | `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
609
661
  | `steer_cut.rb` | `SteerCut`: the Engine's seam for a message cutting the generation |
610
662
  | `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
663
+ | `effective_model.rb` | `EffectiveModel` (the effective model's settings, a snapshot per read; `.of_session`), `ModelChoice`, the fallbacks' log lines (`Unresolved`) |
664
+ | `session_setting.rb` | `SessionSetting`: the per-session settings' descriptors (own value, chips, the web's fallback) |
665
+ | `level_setting_command.rb`, `thinking_command.rb`, `reasoning_history_command.rb` | `/thinking` and `/reasoning-history` (`LevelSettingCommand` and its two Specs) |
666
+ | `thinking_notices.rb` | `ThinkingNotices`: the notices a turn's thinking level leads to |
611
667
  | `steer_cut_after.rb`, `cut_policy.rb` | The model's cut wait (`SteerCutAfter.resolve`); the loops' cut path (`CutPolicy`) |
612
668
  | `steer.rb` | Queued input and steers at iteration boundaries |
613
669
  | `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
@@ -25,13 +25,14 @@ right-hand column happens.
25
25
  ┌──────────────────────────────────────────────────────────────────────────┐
26
26
  │ tools built-ins, then plugin tools sorted by (bundle, name), │ a tool added/changed,
27
27
  │ the tool index's find_tools/call_tool as bundle "index" │
28
- │ (declared once at load; publishes change only results) │
28
+ │ (per toolkit; MCP lines from load, the chi line per toolkit)│
29
29
  │ those the session's toolkit declares (per toolkit) │ /model, a profile change,
30
30
  │ (chat: the `tools` field; native: the model's template) │ a toolkit change
31
31
  ├──────────────────────────────────────────────────────────────────────────┤
32
32
  │ SYSTEM, stable part SystemPrompt#system_prompt_with_index
33
33
  │ [native thinking token] base prompt │
34
- │ not-loaded line (only when the toolkit leaves tools out) │
34
+ │ not-loaded line (only when the toolkit leaves tools out: │
35
+ │ not declared / removed) │
35
36
  │ rg guidance │ /thinking level,
36
37
  │ system identity (+ model overlay) │ /model, editing a
37
38
  │ model notes (model_notes_* matching the model) │ memory / AGENTS.md
@@ -70,6 +71,12 @@ right-hand column happens.
70
71
  beside `apply_staged_tools!`, then `reset!`): the chat loop builds `tools:` per request, so a mid-turn change
71
72
  would differ from the turn's prompt. Under `full` nothing is filtered and no line is added: the bytes are what
72
73
  they were before toolkits.
74
+ - find_tools' description is a function of the toolkit and the source groups **at load**: `ToolIndex#declare`
75
+ snapshots the MCP groups' lines (name, summary, callable count) and `ToolIndex#refresh!` (from
76
+ `ToolkitBinding#apply!`) rebuilds only the `chi` line, the built-ins the toolkit reaches. An MCP server that
77
+ finishes booting between load and the first turn changes search answers only, so two sessions on the same
78
+ toolkit with the same MCP cache send the same bytes. Whether find_tools/call_tool are declared at all reads the
79
+ same two things (`Registry#index_needed?`), never live groups.
73
80
  - On remote Claude, `LLM::PromptCache.mark` sends the system message as two text parts. The stable part carries
74
81
  breakpoint #1, so a **new session** reads it from the cache. The last non-system message carries breakpoint
75
82
  #2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
data/docs/plugins.md CHANGED
@@ -25,6 +25,8 @@ plugin:
25
25
  file: plugin.rb
26
26
  sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
27
27
  requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
28
+ # requires_chi_next: true # shipped bundles only, ignored by chi: rake release:bump raises requires_chi
29
+ # to the chi being released (docs/releasing.md)
28
30
  needs: [gh] # optional: outside commands it relies on (see memory.md#bundles-that-need-outside-commands)
29
31
  ```
30
32
 
@@ -294,6 +296,8 @@ is what `targets:` says:
294
296
  (chi's `call_tool`, which calls a tool a [source](#chitool_sourcecall-acts_as-label-wait-prefix) published). Rules keyed by that tool's name match the
295
297
  call too, as well as rules on the tool's own name. It can't be one of
296
298
  chi's own tools or another bundle's tool: that is dropped (and logged).
299
+ The "another bundle's tool" drop doesn't apply to chi's `call_tool`: its
300
+ `acts_as` is always a tool its sources published.
297
301
  - `args:`: the arguments it acts with (a Hash), when they aren't the call's
298
302
  own (a dispatcher's inner arguments). A call with no `command:` or
299
303
  `paths:` is asked about with them, and "allow this call" is keyed by them.
@@ -583,15 +587,32 @@ chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
583
587
 
584
588
  Tools the model reaches through a search instead of declaring them one by
585
589
  one: the plugin publishes them into chi's **tool index**, and chi declares
586
- two tools in all, whatever the number of sources: `find_tools` (a keyword
587
- search that answers each match's `inputSchema`) and `call_tool` (calls one as
588
- `<group>/<tool>`; its row label is the first source's `label`). They
589
- are declared once, after the plugins load, and only when a plugin registered
590
- a source: a session without one declares neither. Nothing a source publishes
591
- later changes the request's tools, only what a search answers, so the prompt
592
- cache keeps (the search tool's description lists the groups as they were at
593
- load). The mcp bundle is the only source so far (a group per server), and the
594
- texts speak of MCP servers.
590
+ two tools in all, whatever the number of sources: `find_tools(query,
591
+ group?)` (a keyword search that answers each match's `inputSchema`) and
592
+ `call_tool(tool, args)` (calls one as `<group>/<tool>`; its row label is the
593
+ first source's `label`). The index has one group of its own, **`chi`**: the
594
+ built-ins the session's toolkit leaves out but doesn't remove
595
+ ([toolkits](configuration.md#toolkits)), each with its registered schema.
596
+ The two tools are registered once, after the plugins load, and declared per
597
+ toolkit: when a plugin registered a source then, or when the toolkit has a
598
+ reachable built-in (`full` with no source declares neither). Nothing a
599
+ source publishes later changes the request's tools, only what a search
600
+ answers, so the prompt cache keeps: the search tool's description lists the
601
+ source groups as they were at load, and a `chi` line first, per toolkit.
602
+ The mcp bundle is the only source so far (a group per server). A source's
603
+ group named `chi`, or named like an earlier group, is left out of search,
604
+ lookup and the description, with one warning per name.
605
+
606
+ **A built-in through `call_tool` runs as that built-in.** `call_tool(tool:
607
+ "chi/delegate", args: {…})` (or a bare `delegate` while it's reachable) is
608
+ turned into the `delegate` call itself before the guardrails, so rules on
609
+ `delegate`, approvals (keyed `delegate:…`, the same as a direct call's),
610
+ `before_tool_call`/`after_tool_call` hooks and the row see `delegate`, with
611
+ `call[:via] == "call_tool"`; the row reads "delegate (via call_tool)", live
612
+ and after a reload. `tool: call_tool` rules see MCP calls and misses only. A
613
+ `chi/` name the toolkit removes or declares, or any built-in once
614
+ `call_tool` itself is removed, is refused by its name before the guardrails
615
+ (no hook, no card).
595
616
 
596
617
  ```ruby
597
618
  @source = chi.tool_source(label: "mcp", prefix: "mcp_",
@@ -621,7 +642,8 @@ index = Samagotchi::Tools::ToolIndex
621
642
  block returns (a String, a `ToolResult` with images).
622
643
  - **Guardrails** see a `call_tool` as the tool it calls: its `acts_as`, its
623
644
  arguments and `"<group>: <tool>"` as the question's label
624
- ([acts_as:](#guardrails)). A call made before its tool is published is
645
+ ([acts_as:](#guardrails)), even when another bundle has a tool of that
646
+ name (only the core-tool drop applies). A call made before its tool is published is
625
647
  named by `acts_as` from the names alone; a bare name with `prefix` is
626
648
  taken as an `acts_as` as given. The `acts_as:` hook must give exactly the
627
649
  `acts_as` the source publishes for that tool: a call whose tool isn't the
@@ -632,11 +654,11 @@ index = Samagotchi::Tools::ToolIndex
632
654
  what the two tools take in every request (none when other tools had both
633
655
  names). A `Group` in a state other than the three, holding another group's
634
656
  tool, or a `Tool` with a blank `acts_as` raises `ArgumentError`.
635
- - `find_tools` and `call_tool` aren't chi's own tools: every toolkit declares
636
- them (unless its `remove:` names them), as it does plugin tools. A bundle
637
- that declares tools of those names itself (an mcp bundle before 0.7.0 has
638
- `find_mcp_tools` and `mcp_call`) keeps them, and with no source chi
639
- declares neither.
657
+ - `find_tools` and `call_tool` aren't chi's own tools: a toolkit that needs
658
+ them declares them (unless its `remove:` names one; `call_tool` stays
659
+ usable with `find_tools` removed). A bundle that declares tools of other
660
+ names itself (an mcp bundle before 0.7.0 has `find_mcp_tools` and
661
+ `mcp_call`) keeps them; with no source, `full` declares neither of chi's.
640
662
 
641
663
  ### `chi.peer_source(name, label:)`
642
664
 
@@ -1064,7 +1086,7 @@ bundles:
1064
1086
 
1065
1087
  - **Tools: search, then call.** The servers' tools are kept in chi's tool index;
1066
1088
  the model has two fixed tools:
1067
- - `find_tools(query, server?)` searches it by keywords over the
1089
+ - `find_tools(query, group?)` searches it by keywords over the
1068
1090
  server's name, the tool's name and its description, and answers up to 5
1069
1091
  tools, each with its name `<server>/<tool>`, its description and its
1070
1092
  whole `inputSchema`. A word counts more the fewer tools have it (a word
@@ -1089,7 +1111,7 @@ bundles:
1089
1111
  `args` are typed by the tool's
1090
1112
  `inputSchema` (numbers and booleans given as text, JSON text for an
1091
1113
  object). A name it doesn't know answers the closest ones (`Error: no
1092
- MCP tool chrome/open_url. Closest: chrome/new_page, …`). Its row shows
1114
+ tool chrome/open_url. Closest: chrome/new_page, …`). Its row shows
1093
1115
  `mcp chrome/take_screenshot fullPage=true`.
1094
1116
  - A tool turn takes a few more steps than when every tool was declared
1095
1117
  (the P1 spike: about 1.5 more on a 30-tool server), and each request is
@@ -1159,8 +1181,9 @@ bundles:
1159
1181
  session" (or in this repo) allows that tool with those arguments only.
1160
1182
  (Approvals stored before 0.6.0, under the old name, don't carry over, nor
1161
1183
  ones stored before 0.7.0 under `mcp_call`: each MCP tool is asked about once more.)
1162
- Two things see `call_tool`, not the tool: hooks (a `before_tool_call`
1163
- event's `tool:` is `"call_tool"`, the tool in its args), and loop-guard's
1184
+ Two things see `call_tool`, not the MCP tool: hooks (a `before_tool_call`
1185
+ event's `tool:` is `"call_tool"`, the tool in its args; a built-in reached
1186
+ through `call_tool` is the exception, it fires as that built-in), and loop-guard's
1164
1187
  `ignore_tools` (`call_tool` ignores every MCP call; calls to different
1165
1188
  tools, or with different args, stay distinct, since the tool is in the
1166
1189
  args).