samagotchi 0.120.0 → 0.121.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/docs/configuration.md +29 -10
  4. data/docs/internals/participants.md +3 -1
  5. data/docs/internals/prompt-caching.md +7 -6
  6. data/docs/plugins.md +24 -8
  7. data/docs/testing.md +3 -1
  8. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  10. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/engine.rb +1 -0
  15. data/lib/samagotchi/kernel_loop.rb +5 -0
  16. data/lib/samagotchi/plugin/api.rb +6 -3
  17. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  18. data/lib/samagotchi/tool_declarations.rb +1 -1
  19. data/lib/samagotchi/tool_runner.rb +37 -1
  20. data/lib/samagotchi/toolkit.rb +50 -26
  21. data/lib/samagotchi/toolkit_binding.rb +32 -0
  22. data/lib/samagotchi/toolkit_command.rb +23 -0
  23. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  24. data/lib/samagotchi/tools/registry.rb +20 -6
  25. data/lib/samagotchi/tools/tool_index.rb +103 -20
  26. data/lib/samagotchi/version.rb +1 -1
  27. data/lib/samagotchi/web/participant_review.rb +9 -5
  28. data/lib/samagotchi/web/public/forms.css +12 -0
  29. data/lib/samagotchi/web/public/index.html +7 -5
  30. data/lib/samagotchi/web/public/level_chip.js +2 -2
  31. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  32. data/lib/samagotchi/web/public/participant.html +1 -0
  33. data/lib/samagotchi/web/public/participant.js +13 -4
  34. data/lib/samagotchi/web/public/participants_panel.js +3 -3
  35. data/lib/samagotchi/web/public/question_card.js +15 -1
  36. data/lib/samagotchi/web/public/question_cards.js +11 -2
  37. data/lib/samagotchi/web/public/review_history.js +10 -2
  38. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  39. data/lib/samagotchi/web/tunnel.rb +10 -1
  40. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b242fb85587a862bad996a05e189367e5a4e1b9d025df7a7d335d60b2184fe4
4
- data.tar.gz: 85ababba9d74adb82a892cc96d8e2a9fc430a36caf55e9fa9bf549b0f502d136
3
+ metadata.gz: 78051f4f253f400d0b791fe2694f0dc3d07a19e7d1268ef30557ff3a780d8d64
4
+ data.tar.gz: c0bf5fbba8c530df5bf56a3947addddc0a9952a39f2077ddd5f59129683ba8f6
5
5
  SHA512:
6
- metadata.gz: 179d89d141810eccc0038f7eac484775c17cc6b07b80504e4139a6ad0579ebb51fe344619bbb949ad9d5a183c5e82fff90915015915a12bdfac46a5b4589cde2
7
- data.tar.gz: faeb2bde8eab54b4c286f596e9e3005678d1a6aeebc4758af00aa33da12e4ec5492627a6edc4b7f19c7343e361b0501e3ad92ef53edcf7684255241904e14006
6
+ metadata.gz: c438bac307263d1a56cd9a97b56a2c3ab7175d9be0944c4e327fb92a57c9fdd9ffb82bdac8ec7048ac780a4a9e5f3545a3186dfcb3fec7842e687e2d47099307
7
+ data.tar.gz: 2738c2cc9f4f81f34c3c88f02568066dced57578a88ac6d1e301ca62508a3e343179a97a7d6ef9d6778f463a00648d2aa0f0b1aded820e8bce7442b2601a0d8a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.121.0] - 2026-10-26
11
+
12
+ ### Added
13
+
14
+ - Toolkit kits pick the MCP servers their sessions reach: `groups: [chrome]` in `toolkit.kits` (names or globs; no
15
+ key = the base's; the built-ins reach every server, `review` none). `/toolkit` switches them from the next turn
16
+ (asking first: the cached prompt changes). A call into a server the kit doesn't reach is refused without starting
17
+ it, naming a kit that reaches it; its deferred stub says why, `/mcp` marks such servers, and the web's toolkit chip
18
+ lists a restricted kit's servers. Kits without `groups:` send the same request as before.
19
+
20
+ ### Changed
21
+
22
+ - `ask_user_question`'s description asks the model, when it recommends one option, to put it first and end its
23
+ label with " (Recommended)" (eval 0005: Flash Next and DeepSeek v4.1-flash mark it 5/5 on a decision question,
24
+ 0/5 before; an answer comes back with the suffix, so `chi answer --option` takes that exact label or the number).
25
+ - The recommended option shows as such: chi web's question card marks its row in the accent colour with a small
26
+ "recommended" tag (the label shown, and the answered line, without the suffix; nothing is pre-checked), and the
27
+ terminal's question paints its row bold. Approval and step-limit cards are unchanged.
28
+ - chi web's select boxes have one shape in every dialog: the Invite panel's person/agent and Expires now match the
29
+ text fields beside them (flat box, same height and corners, one thin caret), as the Thinking and LLM context
30
+ dialogs' selects already did, in place of the browser's own pill.
31
+
32
+ ### Fixed
33
+
34
+ - claude-peer (0.4.4): `pair --inbox-socket PATH` says the remote socket is forwarded to this Claude's socket, not
35
+ from it (`ssh -R`).
36
+ - An ended participant link forgets its comment history even when its first review read is refused.
37
+ - The participant review status stays idle when a live snapshot has no current turn and its status is stale.
38
+ - The participant usage row no longer says "0 queued" when no worker is running.
39
+ - The participant usage row counts link time down and shows its end while the page stays open.
40
+
41
+ Update with `chi update` (the mcp bundle 0.7.2, claude-peer 0.4.4, the dev profile 0.7.3).
42
+
10
43
  ## [0.120.0] - 2026-10-26
11
44
 
12
45
  ### Added
@@ -4518,7 +4551,8 @@ and long-lived sessions.
4518
4551
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4519
4552
  hotkey panel that send selected text or the clipboard to your sessions.
4520
4553
 
4521
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.120.0...HEAD
4554
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.121.0...HEAD
4555
+ [0.121.0]: https://github.com/dm1try/samagotchi/compare/v0.120.0...v0.121.0
4522
4556
  [0.120.0]: https://github.com/dm1try/samagotchi/compare/v0.119.0...v0.120.0
4523
4557
  [0.119.0]: https://github.com/dm1try/samagotchi/compare/v0.118.0...v0.119.0
4524
4558
  [0.118.0]: https://github.com/dm1try/samagotchi/compare/v0.117.0...v0.118.0
@@ -1400,6 +1400,8 @@ toolkit:
1400
1400
  kits: # your own, on a base
1401
1401
  audit: { base: lean, add: [web_fetch, delegate], remove: [memory_write] }
1402
1402
  terse: { base: lean, prompt: "- Be concise in your responses" }
1403
+ web: { base: lean, groups: [chrome] }
1404
+ coding: { base: lean, groups: [] }
1403
1405
  ```
1404
1406
 
1405
1407
  | toolkit | declares |
@@ -1412,8 +1414,21 @@ toolkit:
1412
1414
  | `review` | `read`, `memory_read` (not `forget_outputs`, even under the `forget` layer): what a [participant's](internals/participants.md#turns) turn may call, whatever the session declares (the review boundary, [guardrails](guardrails.md#a-participants-review-boundary)). Reserved: `toolkit.kits.review` is refused ("review is reserved for participants' turns"), and a bundle can't declare it. A session may run on it too, as any kit |
1413
1415
 
1414
1416
  - **Plugin tools** are declared in every toolkit unless its `remove:` names them, and so is `forget_outputs` under
1415
- the `forget` layer. A `kits:` entry takes `base:` (default `full`), `add:`, `remove:` and `prompt:` (any other
1416
- key, a typo or a `tools:` list, is ignored with a warning); it can't reuse a built-in's name (`review`'s least of all: it is the participants' boundary). A bundle declares its own with `chi.toolkit` ([plugins](plugins.md#chitoolkitname-base-add--remove--prompt-nil)).
1417
+ the `forget` layer. A `kits:` entry takes `base:` (default `full`), `add:`, `remove:`, `prompt:` and `groups:`
1418
+ (any other key, a typo or a `tools:` list, is ignored with a warning); it can't reuse a built-in's name
1419
+ (`review`'s least of all: it is the participants' boundary). A bundle declares its own with `chi.toolkit`
1420
+ ([plugins](plugins.md#chitoolkitname-base-add--remove--prompt-nil-groups-nil)).
1421
+ - **Tool-index groups.** `groups:` selects source groups by name or glob (`chrome`, `db*`); for the MCP bundle
1422
+ each group is a server. Selectors are sorted and deduplicated and match the groups declared when plugins load.
1423
+ An omitted key inherits the base's selectors; `full` and `lean` (and `child`, which is on `lean`) reach all
1424
+ groups, `review` (the participants' kit) reaches none, `groups: ["*"]` matches every group, and `groups: []`
1425
+ reaches none. A kit whose base doesn't resolve (an unknown name, a cycle) falls back to `full`, which reaches
1426
+ every group.
1427
+ A selector that matches no group in that load-time snapshot warns once. `find_tools` and `call_tool` are
1428
+ declared only when the toolkit reaches a source group or has a reachable built-in. Calls naming an unreached
1429
+ group are refused before guardrails and do not start its server. The first-run init wait is unchanged: it can
1430
+ still block the first turn even when its server's group is unreached; `start: eager` and stale-cache refreshes
1431
+ also still start. `/mcp` marks servers outside the current toolkit.
1417
1432
  - **A kit's prompt text.** `prompt:` (text, several lines allowed) goes into the system prompt of the sessions on
1418
1433
  that kit, verbatim, with no header: after AGENTS.md and the appended files, before the memory indexes. A kit
1419
1434
  without one inherits its base's (`audit: {base: terse, add: [web_fetch]}` stays concise); `prompt: ""` clears
@@ -1422,12 +1437,13 @@ toolkit:
1422
1437
  fits the kit's work (the coordinator bundle's `coding`: be concise): one that isn't text or is over 2000
1423
1438
  characters is left out with a warning, and long text belongs in `append_system_prompt:`. `/toolkit` shows it
1424
1439
  (`prompt: …`), `/stats` its kit and size (`kit prompt:`), the web's tools chip in its tooltip.
1425
- - **Saved at creation, never re-resolved from config.** A session stores its toolkit as named
1440
+ - **Saved at creation, definitions resolve at worker start.** A session stores its toolkit as named
1426
1441
  (`"toolkit": {"name": "lean", "add": [], "remove": []}`) when it is created: `toolkit.default`, `toolkit.child`
1427
1442
  for a delegate child (or the kit its `delegate` call names), or `chi --toolkit NAME` / `--tool` / `--no-tool` ([cli](cli.md)). Changing `toolkit.default`
1428
1443
  doesn't move existing sessions, not even when their worker is woken again; a session from before toolkits is on
1429
- `full`. A continue and a fork keep the toolkit. `/toolkit` shows and changes it. The prompt text is part of the
1430
- kit, not of the session file: it follows the name.
1444
+ `full`. A continue and a fork keep the toolkit name. `/toolkit` shows and changes it. The kit's tools, prompt
1445
+ text and group selectors are part of its definition, not the session file: edits reach a saved name at its next
1446
+ worker start.
1431
1447
  - **Left out: reachable or removed.** A built-in missing from a kit's base list (`lean` has no `delegate`; a
1432
1448
  `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
1433
1449
  the MCP servers' tools, and `call_tool(tool: "chi/delegate", args: {…})` runs it as `delegate` itself, through the
@@ -1435,10 +1451,10 @@ toolkit:
1435
1451
  tool in a toolkit's `remove:`, a `--no-tool` or a `/toolkit remove` is **removed**: hard, also after an add, and
1436
1452
  call_tool can't reach it (`child` removes `memory_write`, `delegate` and `delegate_result`, so a child on it
1437
1453
  starts no children). To keep a built-in away from the model, name it under `remove:`. `find_tools` and `call_tool`
1438
- are declared when a toolkit has a reachable built-in (or an MCP server is configured); `full` with no MCP server
1454
+ are declared when a toolkit has a reachable built-in or reaches a source group; `full` with no reachable source
1439
1455
  declares neither, and removing them there (`--no-tool call_tool`) leaves nothing out: no prompt line, no chip.
1440
1456
  - **On `api: anthropic`, reachable tools are callable by name.** Every tool `call_tool` reaches (the reachable
1441
- built-ins, the MCP servers' tools as `mcp_<server>_<tool>`) also goes in the request's tools deferred
1457
+ built-ins, tools from reached MCP groups as `mcp_<server>_<tool>`) also goes in the request's tools deferred
1442
1458
  (`defer_loading`: Messages keeps them out of the model's view until something loads them, at a flat ~100 tokens).
1443
1459
  A direct call of one (`list_sessions`, `mcp_fs_read`) runs, under the same guardrails, approvals and rows as
1444
1460
  through `call_tool` (an MCP tool's call is `call_tool`'s). A `find_tools` hit **loads** its matches (up to 5):
@@ -1455,10 +1471,13 @@ toolkit:
1455
1471
  the route: `Error: delegate isn't declared in this session (toolkit lean); call it with call_tool(tool:
1456
1472
  "chi/delegate", args: {…}) (find_tools gives its inputSchema).` A removed tool's: `Error: memory_write isn't
1457
1473
  loaded in this session (toolkit child) and can't be loaded from here; only the user can (/toolkit add
1458
- memory_write).` `/toolkit` lists both kinds, and the web's toolkit chip says them in its tooltip.
1474
+ memory_write).` An MCP tool in an unreached group gets a toolkit-specific refusal before guardrails; a deferred
1475
+ stub for it points to the toolkit instead of suggesting a search. `/toolkit` lists group selectors and the
1476
+ change; the web's toolkit chip says its source-group scope in the tooltip.
1459
1477
  - **Applied at a turn's start.** A change (`/toolkit`, a plugin's `ctx.use_toolkit`) is saved at once and the next
1460
- turn declares it, with its kit's prompt text; once the session has a model reply, `/toolkit` asks first
1461
- ("Changes that restart the prompt cache"), also for a kit with the same tools and another prompt text.
1478
+ turn declares it, with its kit's prompt text and group filter; once the session has a model reply, `/toolkit`
1479
+ asks first ("Changes that restart the prompt cache"), also when only the group selectors change and the
1480
+ declared tools stay the same.
1462
1481
  - **A name that doesn't resolve** (a typo, a bundle that was uninstalled) declares every tool for that process,
1463
1482
  with one `toolkit>` warning at the first turn; the saved name stays, so the bundle's return brings it back.
1464
1483
  - **Versions.** A toolkit's definition (its tools and its prompt text) can change with chi or a bundle, which
@@ -94,7 +94,9 @@ participant** and **one role**, until it expires or is revoked.
94
94
  block under the lock, and `App#code_identity_ok?` declines unless the trade is proxied with the grant's identity.
95
95
  Declined, the code stays and the trade answers `token_bound`, so a forwarded link opened by someone else still
96
96
  opens, once, for the account it was sent to. Owner codes and unbound grants' codes are used up whatever the gate
97
- says, as before.
97
+ says, as before. Owner codes burn on purpose (user decision 2026-10-26, don't "align" them with bound grants): an
98
+ owner code is the whole control plane, so one presented by another identity has leaked and is killed at once;
99
+ the owner prints a new link (spec: proxy_gate_spec "uses up an owner's code someone else presents").
98
100
  - **Which identity string.** The grant binds to whatever the header this run trusts carries: through Okta and
99
101
  oauth2-proxy the work email (`alex@corp.com`), through Tailscale Serve the Tailscale login (`alex@github`, or a
100
102
  private-relay address). The same person is two strings: bind to the one that proxy sends. A request from a
@@ -79,11 +79,13 @@ right-hand column happens.
79
79
  `Toolkit::Resolved` the Registry holds, so a switch that changes only the text is a toolkit change
80
80
  (`apply_toolkit!` → `reset!`). `full` and kits without one send the bytes they sent before.
81
81
  - find_tools' description is a function of the toolkit and the source groups **at load**: `ToolIndex#declare`
82
- snapshots the MCP groups' lines (name, summary, callable count) and `ToolIndex#refresh!` (from
83
- `ToolkitBinding#apply!`) rebuilds only the `chi` line, the built-ins the toolkit reaches. An MCP server that
84
- finishes booting between load and the first turn changes search answers only, so two sessions on the same
85
- toolkit with the same MCP cache send the same bytes. Whether find_tools/call_tool are declared at all reads the
86
- same two things (`Registry#index_needed?`), never live groups.
82
+ snapshots source group names and description lines (name, summary, callable count). `ToolIndex#refresh!` (from
83
+ `ToolkitBinding#apply!`) filters that snapshot by the new toolkit and rebuilds the `chi` line from the built-ins
84
+ it reaches; it never consults live group names. The same group filter drives searches, deferred MCP tools and
85
+ whether `find_tools`/`call_tool` are declared (`Registry#index_needed?`). An MCP server that finishes booting
86
+ between load and the first turn changes search answers only, so two sessions on the same toolkit with the same
87
+ MCP cache send the same bytes. Kits that omit `groups:` keep their previous request bytes; switching only
88
+ groups changes the prompt key and asks `/toolkit` to confirm the cache cost once the session has history.
87
89
  - A worker's `execute` and `task_create` descriptions carry the finish wake's sentence ("if you end your turn before
88
90
  it finishes, chi tells you when it does"; `Engine.new(task_finish_notes:)`, set once as the worker builds its
89
91
  Engine); the `--no-shared` REPL's, and a worker's under `session.task_wakes: off`, don't. A session that moves
@@ -240,4 +242,3 @@ is prefilled once. The native and the chat loop use the same plan.
240
242
  `cache_prompt: true` on every native request.
241
243
  - **OpenRouter / Anthropic:** the default cache TTL is 5 minutes (`cache.ttl: 1h` for an hour, see above).
242
244
  Provider routing (which upstream serves a request) also decides whether a cache is there to hit.
243
-
data/docs/plugins.md CHANGED
@@ -579,13 +579,16 @@ end
579
579
  - Each task runs once per session start. A `-p … --non-interactive` run
580
580
  starts them with its turn and shows nothing but the answer.
581
581
 
582
- ### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil)`
582
+ ### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)`
583
583
 
584
584
  A toolkit sessions can be put on: `base`'s tools (`full`, `lean`, `child`, a
585
585
  config or bundle toolkit) with `add` declared and `remove` left out, and
586
586
  `prompt`: text the sessions on it get in their system prompt, verbatim (nil
587
587
  inherits the base's, `""` clears it; at most 2000 characters, and one that
588
- isn't a String or is longer is a load error). Users pick
588
+ isn't a String or is longer is a load error). `groups` selects the source
589
+ groups the toolkit reaches (names or globs, sorted and deduplicated; nil
590
+ inherits the base's, `[]` reaches none, and `:all` reaches every group).
591
+ Groups match against the tool index's load-time names. Users pick
589
592
  it like chi's own (`chi --toolkit NAME`, `toolkit.default`, `/toolkit NAME`),
590
593
  and the plugin with `ctx.use_toolkit` (below). Names are `a-z`, `0-9`, `_` and
591
594
  `-`; one chi has (`full`, `lean`, `child`) or another bundle declared is a
@@ -593,11 +596,13 @@ load error. A session saved on a toolkit whose bundle is gone declares every
593
596
  tool (full) until it comes back, with a warning at its first turn. See
594
597
  [Toolkits](configuration.md#toolkits). `prompt:` needs chi 0.72.0 (an older
595
598
  chi refuses the keyword, a load error: a bundle that passes it sets
596
- `requires_chi: ">= 0.72.0"`).
599
+ `requires_chi: ">= 0.72.0"`). `groups:` needs chi 0.121.0: an older chi refuses that keyword
600
+ too, so a bundle passing it sets `requires_chi: ">= 0.121.0"`.
597
601
 
598
602
  ```ruby
599
603
  chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
600
604
  chi.toolkit "coding", base: "child", prompt: "- Be concise in your responses"
605
+ chi.toolkit "web", base: "lean", groups: %w[chrome db*]
601
606
  ```
602
607
 
603
608
  ### `chi.tool_source(call:, acts_as:, label:, wait:, prefix:)`
@@ -611,12 +616,19 @@ first source's `label`). The index has one group of its own, **`chi`**: the
611
616
  built-ins the session's toolkit leaves out but doesn't remove
612
617
  ([toolkits](configuration.md#toolkits)), each with its registered schema.
613
618
  The two tools are registered once, after the plugins load, and declared per
614
- toolkit: when a plugin registered a source then, or when the toolkit has a
615
- reachable built-in (`full` with no source declares neither). Nothing a
619
+ toolkit when it reaches a source group or has a reachable built-in (`full`
620
+ with no source declares neither). Nothing a
616
621
  source publishes later changes the request's tools, only what a search
617
622
  answers, so the prompt cache keeps: the search tool's description lists the
618
623
  source groups as they were at load, and a `chi` line first, per toolkit.
619
- The mcp bundle is the only source so far (a group per server). A source's
624
+ The mcp bundle is the only source so far (a group per server). `groups:`
625
+ filters the groups find_tools searches, the source descriptions in the
626
+ request, and the MCP tools `call_tool` can reach. Its group snapshot is
627
+ filtered again at each toolkit change; new groups published after load do
628
+ not alter that prompt description. An unreached MCP tool call is refused
629
+ before the guardrails without starting the server; a deferred stub says to
630
+ switch to a toolkit that reaches its group. `/mcp` marks servers outside the
631
+ session's toolkit. A source's
620
632
  group named `chi`, or named like an earlier group, is left out of search,
621
633
  lookup and the description, with one warning per name.
622
634
 
@@ -1377,8 +1389,12 @@ bundles:
1377
1389
  its tools as `(failed: …)`. The saved list stays: the next session tries
1378
1390
  again.
1379
1391
  - **`start: eager`** on a server starts it with every session (in an init
1380
- task, after the Bridge is up), for a server whose start does something
1381
- you want at once.
1392
+ task, after the Bridge is up), even when that session's toolkit does not
1393
+ reach its group. A first-run init task still blocks the first turn until
1394
+ it finishes, even for an unreached group. A stale-cache refresh likewise
1395
+ starts its short-lived server regardless of the toolkit. Switching away
1396
+ from a group refuses its calls without starting a lazy cached server, and
1397
+ does not stop a server already running; switching back reuses it.
1382
1398
  - **`npx -y …@latest` checks the npm registry on every start** (~3.4 s for
1383
1399
  `chrome-devtools-mcp`, against ~0.9 s for the installed binary). The cache
1384
1400
  hides it from new sessions, but not from the first call. For a faster first
data/docs/testing.md CHANGED
@@ -230,7 +230,9 @@ A builder runs these while working, not the full suite: the specs the change tou
230
230
  files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
231
231
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
232
232
  file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
233
- words the REPL prints, pick it by a rule), then the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
233
+ words the REPL prints, pick it by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
234
+ effective-model, request, prompt-snapshot and full system-prompt goldens because they hash the request's prompt and
235
+ tools. Then come the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
234
236
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
235
237
  `spec/guardrails/context_spec.rb`), then the specs that name a method the change touches (the innermost `def` around a
236
238
  changed line, read with Prism; one `git grep` for up to 12 names; names under 4 characters, `initialize`, `to_s`,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-peer
3
- version: 0.4.3
3
+ version: 0.4.4
4
4
  scope: system
5
5
  description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-outbox context; /claude install-skill installs Claude's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -12,7 +12,7 @@ plugin:
12
12
  file: plugin.rb
13
13
  sha256: sha256:50f427d3862ae9d0d15693b4d887c528162b96c0320cc663df89d59d830d4660
14
14
  scripts:
15
- pair: sha256:bd850e8091098e249ac5b9b04a95a4d8a0dd8501cf9d58639a47d4f3bce1d757
15
+ pair: sha256:106d5b44020602fcd2d8065da30d10e1043e30c1b31ea82b4f27f7f79ada6592
16
16
  chi-peer-SKILL.md: sha256:b9366f8f4aee9c54d209368ce421eab7b971c03c75efab0415d1988fd40f9e60
17
17
  # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
18
18
  # An agreeing answer's closing wake (the skills say an OK wakes chi once)
@@ -147,6 +147,6 @@ attach claude-outbox --why "Claude's answers to what you sent it (claude-peer)"
147
147
  if [ -z "$inbox" ]; then
148
148
  echo "paired with chi $id (socket $sock)"
149
149
  else
150
- echo "paired with chi $id (socket $inbox on chi's machine, forwarded from $sock)"
150
+ echo "paired with chi $id (socket $inbox on chi's machine, forwarded to this Claude's $sock)"
151
151
  fi
152
152
  $chi context ls "$id"
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.7.2
3
+ version: 0.7.3
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.107.0"
7
+ requires_chi: ">= 0.121.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.7.1
3
+ version: 0.7.2
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:67b1ed9337236deb366459aaaed4e49f0d3122a0b38f3ff9328babcd332c0182
10
+ sha256: sha256:c4bb4cf99ad43168ea68c25283a153c5dea1e5be123743c8a4c1278c16b5ea90
11
11
  # 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
12
- requires_chi: ">= 0.67.0"
12
+ requires_chi: ">= 0.121.0"
@@ -354,6 +354,7 @@ class Plugin
354
354
  def register(chi)
355
355
  @chi = chi
356
356
  ctx = chi.ctx
357
+ @ctx = ctx
357
358
  configs = @settings["servers"]
358
359
  configs = {} unless configs.is_a?(Hash)
359
360
  unless configs.empty?
@@ -952,7 +953,10 @@ class Plugin
952
953
  return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
953
954
 
954
955
  servers = @servers.map do |server|
955
- head = "**#{server.name}**: #{state_text(server)}"
956
+ marker = if @source && !@source.declared_groups.include?(server.name)
957
+ " (not in toolkit #{@ctx.toolkit})"
958
+ end
959
+ head = "**#{server.name}**: #{state_text(server)}#{marker}"
956
960
  # Sorted: a server lists its tools in its own (often grouped) order.
957
961
  tools = server.tools.sort_by(&:name).map { |t| ["- `#{t.server}/#{t.name}`", mark(t)].compact.join(" ") }
958
962
  tools.empty? ? head : "#{head}\n#{tools.join("\n")}"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.120.0
3
+ version: 0.121.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -377,6 +377,7 @@ module Samagotchi
377
377
  # A cut tool output is saved whole in the session's folder.
378
378
  @kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
379
379
  @kernel.guardrail_gate = @guardrail_wiring.gate
380
+ @kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
380
381
  # The turn-end warm-up; the kernel asks it for a slot pin per request.
381
382
  @prompt_warmup = PromptWarmup.new
382
383
  @kernel.warmup = @prompt_warmup
@@ -176,6 +176,11 @@ module Samagotchi
176
176
  # The Guardrails::Gate ToolRunner asks before each call; the Engine sets
177
177
  # it (nil: ToolRunner's own, hooks only).
178
178
  attr_accessor :guardrail_gate
179
+ # Whether the current turn belongs to a participant, so pre-gate
180
+ # refusals can defer to ReviewBoundary.
181
+ attr_accessor :participant_turn
182
+
183
+ def participant_turn? = @participant_turn&.call == true
179
184
  # @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
180
185
  # model name, set by the Engine per turn (#run sets the model name
181
186
  # too); the thinking level is its own field, since the native path
@@ -308,12 +308,14 @@ module Samagotchi
308
308
  # toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
309
309
  # a bundle has) with +add+ declared and +remove+ left out, and
310
310
  # +prompt+: text the sessions on it get in their system prompt (nil:
311
- # its base's, "" none; at most Toolkit::PROMPT_MAX characters). A
311
+ # its base's, "" none; at most Toolkit::PROMPT_MAX characters);
312
+ # +groups+: MCP source group names/globs (nil inherits its base).
313
+ # A
312
314
  # name chi (full, lean, child) or another bundle has is a load error,
313
315
  # and so is a prompt that isn't text or is too long; a base that
314
316
  # doesn't resolve leaves the sessions on it with every tool
315
317
  # (Toolkit#resolve).
316
- def toolkit(name, base:, add: [], remove: [], prompt: nil)
318
+ def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
317
319
  name = name.to_s
318
320
  raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
319
321
 
@@ -327,7 +329,8 @@ module Samagotchi
327
329
  end
328
330
  raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
329
331
 
330
- @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt, source: @bundle)
332
+ @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
333
+ groups: groups, source: @bundle)
331
334
  nil
332
335
  end
333
336
 
@@ -15,6 +15,12 @@ module Samagotchi
15
15
  # A numbered option shows as "1) Apple", a word as "yes: continue".
16
16
  Option = Struct.new(:key, :label) do
17
17
  def text = key.match?(/\A\d+\z/) ? "#{key}) #{label}" : "#{key}: #{label}"
18
+
19
+ # The model's pick (a label ending in "(Recommended)"): painted bold,
20
+ # its label kept as is so a typed label still matches.
21
+ def recommended? = label.to_s.rstrip.end_with?("(Recommended)")
22
+
23
+ def code = recommended? ? "1;92" : 92
18
24
  end
19
25
 
20
26
  CONTINUE_QUESTION = "The turn ran out of iterations. Continue it?"
@@ -83,7 +89,7 @@ module Samagotchi
83
89
  question_rows = question_rows.first(1) unless details
84
90
  rows.concat(question_rows.map { |row| @paint.call(row, @question_code) })
85
91
  rows.concat(@details.map { |row| @paint.call(row, @question_code) }) if details
86
- rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}", 92) })
92
+ rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}", o.code) })
87
93
  rows << @paint.call(" [#{@hint}]", 90) if hint && @hint
88
94
  rows
89
95
  end
@@ -91,19 +97,24 @@ module Samagotchi
91
97
  # The options packed into rows of +width+: " 1) Allow once · 2) Deny".
92
98
  def folded_options(width)
93
99
  rows = []
94
- line = +""
100
+ line = []
95
101
  @options.each do |option|
96
- item = option.text
97
- candidate = line.empty? ? " #{item}" : "#{line} · #{item}"
98
- if !line.empty? && display_width(candidate) > width
102
+ if !line.empty? && display_width(" #{(line + [option]).map(&:text).join(" · ")}") > width
99
103
  rows << line
100
- line = " #{item}"
104
+ line = [option]
101
105
  else
102
- line = +candidate
106
+ line << option
103
107
  end
104
108
  end
105
109
  rows << line unless line.empty?
106
- rows.map { |row| @paint.call(row, 92) }
110
+ rows.map { |row| paint_folded(row) }
111
+ end
112
+
113
+ # One folded row; a recommended option in it is painted on its own.
114
+ def paint_folded(options)
115
+ return @paint.call(" #{options.map(&:text).join(" · ")}", 92) if options.none?(&:recommended?)
116
+
117
+ @paint.call(" ", 92) + options.map { |o| @paint.call(o.text, o.code) }.join(@paint.call(" · ", 92))
107
118
  end
108
119
 
109
120
  # Word wrap at +width+ columns, the first row after +first+, the others
@@ -412,7 +412,7 @@ module Samagotchi
412
412
  },
413
413
  {
414
414
  name: "ask_user_question",
415
- description: "Ask the user a structured qualification question. Supports single/multi selection plus optional freeform/Other input. Prefer this over plain numbered lists when you need a clear choice. The harness renders it natively and returns {selected, freeform}.",
415
+ description: "Ask the user a structured qualification question. Supports single/multi selection plus optional freeform/Other input. Prefer this over plain numbered lists when you need a clear choice. The harness renders it natively and returns {selected, freeform}. If you recommend one option, put it first and end its label with ' (Recommended)'.",
416
416
  parameters: {
417
417
  type: "object",
418
418
  properties: {
@@ -77,7 +77,16 @@ module Samagotchi
77
77
  # hook, no approval card for a call that can't run (KernelLoop#dispatch
78
78
  # answers it with the not-loaded error).
79
79
  original = call.dup
80
- verdict = left_out?(call) ? Guardrails::Verdict.new(call: call) : evaluate(call, iteration, params)
80
+ refusal = unreached_group_refusal(call)
81
+ verdict = if refusal
82
+ Guardrails::Verdict.new(call: call).deny!(refusal.message,
83
+ rule: Tools::ToolIndex::ToolkitGroupRefusal::RULE,
84
+ source: "core", decided_by: "core")
85
+ elsif left_out?(call)
86
+ Guardrails::Verdict.new(call: call)
87
+ else
88
+ evaluate(call, iteration, params)
89
+ end
81
90
  call = verdict.call
82
91
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
83
92
  label = plugin_label(call[:name])
@@ -378,6 +387,27 @@ module Samagotchi
378
387
  !registry.left_out(call[:name], layers: layers).nil?
379
388
  end
380
389
 
390
+ def unreached_group_refusal(call)
391
+ return if participant_turn?
392
+
393
+ registry = tools
394
+ index = registry&.index if registry.respond_to?(:index)
395
+ return unless index.respond_to?(:unreached_tool)
396
+
397
+ name = if call[:name] == index.call_name
398
+ args = call[:args].is_a?(Hash) ? call[:args] : {}
399
+ args["tool"] || args[:tool]
400
+ else
401
+ call[:name]
402
+ end
403
+ tool = index.unreached_tool(name)
404
+ tool && Tools::ToolIndex::ToolkitGroupRefusal.for(tool, registry)
405
+ end
406
+
407
+ def participant_turn?
408
+ @kernel.respond_to?(:participant_turn?) && @kernel.participant_turn?
409
+ end
410
+
381
411
  # Whether the turn's host loads tools (TurnSettings#loads_tools).
382
412
  def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
383
413
 
@@ -431,6 +461,12 @@ module Samagotchi
431
461
  # A legacy veto keeps its old text; a verdict's deny tells the model
432
462
  # who decided and not to route around it.
433
463
  def denied(call, verdict)
464
+ if verdict.rule == Tools::ToolIndex::ToolkitGroupRefusal::RULE
465
+ output = "[#{call[:name]}] #{verdict.reason}"
466
+ activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools)
467
+ return { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
468
+ end
469
+
434
470
  output = if verdict.legacy?
435
471
  reason = verdict.reason.to_s.strip
436
472
  "[#{call[:name]}] Error: blocked by guardrail: #{reason.empty? ? "blocked by hook" : reason}"