samagotchi 0.37.0 → 0.38.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15887ec5fe31be3f32d3e1bfa15b9cf5ca73b03e2101f6416eb2cfad4c2f477a
4
- data.tar.gz: 2226f5343806c3171babc66d075fc57498af03d313f180458e4f6ee1801c7384
3
+ metadata.gz: 35846da55e0107127da528067ad179a59cbd7a23eee66c1b770a17d1504c717b
4
+ data.tar.gz: e28ce71127da751668251ddfca44dc8b05a97e038f5d03045357f1cdbac26f20
5
5
  SHA512:
6
- metadata.gz: 8986ea963bda07138d6874b9024a031c5c6c000d03bad8281c9c24f57a079234fab5de68efe08305dd38ea4f138bf7ef7b02637e310e69f061182bb17d01d8a3
7
- data.tar.gz: d9e07e7a5bacd114b8916a538fa2265bbd5b43ed78373fc306766455788b75ef5c1b0eb21d054288887483c93385063293150339abf4e3d02284bc1320cd9509
6
+ metadata.gz: b2be4346aaaa73f05518a5b62ffcfca820f94ea571b5524d1d8013224ff43ee1648951e40ee4290f80f088da6ffec4c26c4de6c8a6905ccc83340919173fe330
7
+ data.tar.gz: 01027d62cb5f9b423196f386c68fc4cbb46d8c73866540e1aacb9778f3a414013a059c5df99df099d528574cb1a5a5d57b0cb764002de0a4524148fd91ad66e8
data/CHANGELOG.md CHANGED
@@ -8,6 +8,43 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.38.0] - 2026-10-07
12
+
13
+ ### Added
14
+
15
+ - The desktop helper's panel has an **Everyone it concerns** row (⌘B): ⌘⏎ there runs `chi broadcast` with the note
16
+ (your line, then the selection; a first line like `shopfront/checkout` keeps it to that project), instead of a
17
+ note to the sessions you pick. The panel shows "broadcasting…", then the summary line, and stays open; `chi
18
+ broadcast log` has the details. It may take `broadcast.triage_deadline` + 10 s: after changing the deadline, run
19
+ `chi update`. Rebuilt by `chi update`. See [docs/desktop.md](docs/desktop.md#everyone-it-concerns).
20
+
21
+ ### Changed
22
+
23
+ - The mcp bundle (0.6.0) no longer declares every MCP tool to the model. It gets two fixed tools: `find_mcp_tools`
24
+ searches the servers' tools by keywords and answers the best 5 with their schemas, and `mcp_call` calls one as
25
+ `<server>/<tool>`. Requests get smaller (about half on a 30-tool server, in a spike on a local model), and the
26
+ model's tools no longer change when a server starts, refreshes or fails, so the prompt cache keeps; a tool turn
27
+ takes a few more steps (search, then call). A failed call answers the tool's schema, so a call can be fixed on the
28
+ next step. A server's new optional `description:` names it in the search tool, else the first sentence of its
29
+ instructions. Guardrail rules on `mcp_<server>_<tool>` names still match (`mcp_call` acts as that tool), and the
30
+ question names `<server>: <tool>`; approvals stored under the old names don't carry over. Hooks and loop-guard's
31
+ `ignore_tools` see `mcp_call`. `/mcp` lists the tools as `<server>/<tool>` and says what every request carries.
32
+ Needs chi 0.37.0. See [docs/plugins.md](docs/plugins.md#the-mcp-bundle).
33
+
34
+ ### Fixed
35
+
36
+ - `chi broadcast`'s triage ends at most a second after `broadcast.triage_deadline` (requests that ignore the cancel
37
+ could hold it up to ~4 s past it), turns logprobs off for a host only when its 400 was about them, and two broadcasts
38
+ at once no longer both rotate the triage log (one failed, or the old lines were lost). A scope card's folder is right
39
+ under a symlinked path (`/tmp` on macOS read `../../../tmp/…`), and the summary line shortens a reason that quotes a
40
+ setting (`3 unchecked: no triage model: broadcast.triage_host_ref`).
41
+ - `max_tool_output_chars` caps the tool outputs the model gets back on the native loop too (a host without
42
+ `api: openai`): it fed each output whole, so one long result could fill the context. On both loops a cut output now
43
+ ends with `[cut: N of M chars; read it in parts]`, so the model knows it didn't get it all (the chat loop cut it
44
+ silently); the tool row shows the same text.
45
+
46
+ Update with `chi update` (mcp 0.6.0; it also rebuilds the desktop helper for the new row).
47
+
11
48
  ## [0.37.0] - 2026-10-07
12
49
 
13
50
  ### Added
@@ -1805,7 +1842,8 @@ and long-lived sessions.
1805
1842
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1806
1843
  hotkey panel that send selected text or the clipboard to your sessions.
1807
1844
 
1808
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.37.0...HEAD
1845
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.38.0...HEAD
1846
+ [0.38.0]: https://github.com/dm1try/samagotchi/compare/v0.37.0...v0.38.0
1809
1847
  [0.37.0]: https://github.com/dm1try/samagotchi/compare/v0.36.0...v0.37.0
1810
1848
  [0.36.0]: https://github.com/dm1try/samagotchi/compare/v0.35.0...v0.36.0
1811
1849
  [0.35.0]: https://github.com/dm1try/samagotchi/compare/v0.34.0...v0.35.0
data/README.md CHANGED
@@ -179,7 +179,7 @@ only tells you (`context.wake`). See [Attached context](docs/context.md).
179
179
  `chi desktop install` builds a small native helper: select text in any app →
180
180
  Services → **Send to chi** (or ⌃⌥⌘N with the clipboard) → pick sessions in a
181
181
  Spotlight-like panel → ⏎ sends it quoted under your question as a message, ⌘⏎ as a
182
- context note. It needs the
182
+ context note (on the "Everyone it concerns" row, a `chi broadcast`). It needs the
183
183
  Command Line Tools. See [Desktop helper](docs/desktop.md).
184
184
 
185
185
  ### Guardrails
data/docs/broadcast.md CHANGED
@@ -16,7 +16,9 @@ chi broadcast log # the last broadcasts and who got them
16
16
  chi broadcast deliver b-7f3a1c9e c0ffee12 # one it skipped, after all
17
17
  ```
18
18
 
19
- To tell one session or a few you picked, use `chi note ID...` instead.
19
+ To tell one session or a few you picked, use `chi note ID...` instead. From
20
+ any app, the desktop helper's **Everyone it concerns** row broadcasts the
21
+ selection: see [Desktop helper](desktop.md#everyone-it-concerns).
20
22
 
21
23
  ## Who it reaches
22
24
 
@@ -157,7 +159,8 @@ Each broadcast (not a `--dry-run`) leaves one line in
157
159
  id, time, your text, the note's tags and each recipient's verdict (result,
158
160
  reason, P(yes), who decided, the session's tags). Scope cards are not kept:
159
161
  they quote your prompts. Past 2 MiB the file becomes `log.jsonl.1`, one old
160
- file kept. Sessions don't read it: a broadcast stays fire-and-forget.
162
+ file kept (`log.jsonl.lock` keeps two broadcasts at once from both rotating
163
+ it). Sessions don't read it: a broadcast stays fire-and-forget.
161
164
 
162
165
  ```sh
163
166
  chi broadcast log # the last 5 broadcasts
@@ -924,7 +924,7 @@ described in their own sections.
924
924
  | `thinking.level` | `default` | `--thinking` | `off`, `low`, `medium`, `high` or `default` for every model; the flag and env outrank the `models:`/`hosts:` entries, the file's value doesn't. See "Thinking". |
925
925
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
926
926
  | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
927
- | `max_tool_output_chars` | `10000` | yes | Tool output kept in the conversation; a top-level key (see below). |
927
+ | `max_tool_output_chars` | `10000` | yes | Characters of each tool output kept in the conversation (both loops); a longer one is cut and ends with `[cut: N of M chars; read it in parts]`. A top-level key (see below). |
928
928
  | `cache.warmup` | `auto` | | `auto`: after a turn, send the next turn's prompt (up to the next message) to a local llama.cpp host on the native loop, so the next turn prefills only its message; never a remote or `api: openai` host. `off`: never. See [prompt caching](internals/prompt-caching.md#the-turn-end-warm-up). |
929
929
  | `cache.ttl` | `5m` | | How long Anthropic keeps the prompt-cache breakpoints of a Claude model on a remote host: `5m` (Anthropic's default) or `1h`. A 1h cache write costs 2× the input price (5m: 1.25×), a read the same 0.1×. See "Remote or local". |
930
930
  | `cache.key` | `off` | | `session`: send the session id as `prompt_cache_key` to OpenAI's API and OpenRouter, which route and cache by it; no other host gets it. `off`: none. |
data/docs/desktop.md CHANGED
@@ -38,6 +38,22 @@ note needs a session. After the send the panel shows `started <id>…` for 3 s.
38
38
  the model the new session starts on (`chi self --model`: `default.model` from config.yml, or its env override; an
39
39
  alias as the model it names).
40
40
 
41
+ ### Everyone it concerns
42
+
43
+ The second row, **Everyone it concerns** (⌘B), shares a note with every session it may concern instead of the ones
44
+ you pick: ⌘⏎ (the **Broadcast** button) runs [`chi broadcast`](broadcast.md) with the line, then the text, on stdin.
45
+ chi picks the sessions (a shared ticket, PR or link, else a small triage model), so the preselected session doesn't
46
+ count: the row is ticked alone, like the new row. Write it the way you'd tell a colleague: a first line such as
47
+ `shopfront/checkout` keeps the note to that project's sessions, and the rest is the selection.
48
+
49
+ - The panel shows "broadcasting…", then the broadcast's id and chi's summary line (`b-7f3a1c9e: delivered 4 ·
50
+ skipped 2 · 1 unchecked: triage deadline`), and **stays open**: Esc closes. Which sessions got it and why:
51
+ `chi broadcast log` in a terminal.
52
+ - A broadcast is a text note: ⏎ and images beep. The source field and Send are hidden (its source is `broadcast`).
53
+ - It may take `broadcast.triage_deadline` (20 s) and 10 s more before the helper stops it ("the note may be partly
54
+ delivered"). The helper reads that from its launch file: after changing the deadline, run `chi update` (or
55
+ `chi desktop upgrade`).
56
+
41
57
  ### Choosing the model
42
58
 
43
59
  Click the model on the new row, or press **⌘M**, to choose another one for the new session. The chooser takes the
@@ -179,7 +195,9 @@ exports. So `install` writes `~/Library/Application Support/Chi Helper/launch.js
179
195
  - the absolute path of the Ruby running chi and of chi itself (the gem's wrapper, or a checkout's `bin/chi`);
180
196
  - `LANG=en_US.UTF-8`;
181
197
  - only these variables, and only when they are set: `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `GEM_HOME`, `GEM_PATH`,
182
- `RUBYLIB`. No tokens and no `SAMAGOTCHI_*` settings go in.
198
+ `RUBYLIB`. No tokens and no `SAMAGOTCHI_*` settings go in;
199
+ - the settings the panel needs from config.yml, as chi read them then: the `kitty:` section (when
200
+ `kitty.listen_on` is set) and `broadcast_timeout` (`broadcast.triage_deadline` + 10 s).
183
201
 
184
202
  The file freezes the install shell's values. If you install with a temporary `XDG_STATE_HOME`, the helper keeps
185
203
  using it. `status` prints the baked dirs.
@@ -194,10 +212,14 @@ The helper uses only these commands, so it could ship on its own later:
194
212
  - `chi send [-m LINE] [--image PATH]... ID...` and `chi send --new --dir DIR [--model NAME] [-m LINE] [--image PATH]...`
195
213
  with the text on stdin (or none), and `chi note [--source NAME] ID...` with the text on stdin → one line per session
196
214
  on stdout; exit 0 means all sent or queued, 1 means some were refused or failed.
215
+ - `chi broadcast` with the text on stdin → its id first (`broadcast b-…`), a line per recipient, then the summary line
216
+ (`delivered N · skipped M…`); exit 0 when it ran, 1 when refused or a delivery failed.
197
217
  - `chi self --model` → the model a new session starts on (the panel's hint).
198
218
  - `chi models --format json` → the models the hosts offer (the new-session model picker).
199
219
 
200
- Each call is stopped after 10 s, a send with images after 30 s. A stopped `chi note` says the note may be partly delivered.
220
+ Each call is stopped after 10 s, a send with images after 30 s, a broadcast after the launch file's
221
+ `broadcast_timeout` (`broadcast.triage_deadline` + 10 s; 30 s when it has none). A stopped `chi note` or `chi
222
+ broadcast` says the note may be partly delivered.
201
223
 
202
224
  ## Troubleshooting
203
225
 
data/docs/guardrails.md CHANGED
@@ -289,7 +289,7 @@ A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
289
289
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
290
290
  `{a,b}`, matched with `File.fnmatch`). `/guardrails` lists the glob as given.
291
291
  A plugin tool whose `targets:` name no command or path (an MCP tool) is asked
292
- about with its arguments (`mcp_x_sum: a=20 b=22`), and an approval of "this
292
+ about with its arguments (`x: sum: a=20 b=22`), and an approval of "this
293
293
  call" is keyed by them.
294
294
 
295
295
  A plugin tool that runs other tools for the model (a dispatcher such as
@@ -309,7 +309,7 @@ name.
309
309
 
310
310
  ```yaml
311
311
  - id: mcp-ask
312
- tool: "mcp_*" # every MCP tool (the mcp bundle's mcp_<server>_<tool>)
312
+ tool: "mcp_*" # every MCP call (mcp_call, acting as mcp_<server>_<tool>); not a find_mcp_tools search
313
313
  verdict: ask
314
314
  reason: an MCP server's tool
315
315
  ```
data/docs/hooks.md CHANGED
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
56
56
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
57
57
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
58
58
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
59
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
59
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars, a cut one ending in a [cut: N of M chars …] line), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
60
60
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
61
61
 
62
62
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -444,7 +444,8 @@ stripped of surrounding whitespace, except file text, an edit's
444
444
  A `write`'s `content:` is the file text; an `edit` carries `old_text:` and
445
445
  `new_text:` (its `content:` is `""`). A plugin or MCP tool's call has its
446
446
  arguments whole on `args:` (string keys) instead, and its `content:` is those
447
- arguments as JSON.
447
+ arguments as JSON. An MCP tool's call is the mcp bundle's `mcp_call`: `tool:
448
+ "mcp_call"`, with `args: {"tool" => "<server>/<tool>", "args" => {…}}`.
448
449
 
449
450
  ## Bundle Hooks (unified workflow bundle)
450
451
 
@@ -192,7 +192,7 @@ One iteration with tool calls:
192
192
  → returns {output:, capped_output:, truncated:, activity:, images:, shown_params:,
193
193
  shown_label:, diff:}
194
194
  → :tool_dispatch_completed {iteration, call_count}
195
- → native: ToolResponse.joined(runs), one entry with the full outputs
195
+ → native: ToolResponse.joined(runs), one entry with the capped outputs
196
196
  chat: ToolResponse.single(run, tool_call_id:) per call, with the capped output
197
197
  ```
198
198
 
@@ -207,7 +207,9 @@ One iteration with tool calls:
207
207
  - For edit and write: diffs the file before and after, and refreshes a memory's index line
208
208
  (`MemoryBundle::IndexSync`).
209
209
  - Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
210
- - Scrubs bytes that aren't UTF-8, and caps the event's output at `max_tool_output_chars`.
210
+ - Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars`: a longer one is cut and ends
211
+ with `[cut: N of M chars; read it in parts]` (N kept of M, the whole within the cap unless the cap is shorter
212
+ than that line). The model (both loops), the event and the after_tool_call hook get that text.
211
213
  - Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, activity,
212
214
  images, diff, waited_ms, view}, then fires `:after_tool_call` {iteration, tool, output, status}.
213
215
 
data/docs/plugins.md CHANGED
@@ -452,8 +452,10 @@ end
452
452
  start`; the card shows the bundle beside it), and its body the message;
453
453
  without `failed:` the title is `setup failed` and the body
454
454
  `<label>: <message>`.
455
- - **`provides_tools: true`**: the task brings tools (with
456
- `chi.replace_tools`). A turn sent while it runs starts at once (the user's
455
+ - **`provides_tools: true`**: the task provides what the plugin's tools
456
+ need: tools of its own (with `chi.replace_tools`), or what fixed tools
457
+ read (the mcp bundle's index of a first-run server's tools, which
458
+ `find_mcp_tools` searches). A turn sent while it runs starts at once (the user's
457
459
  message shows), then waits for it **before its first model request**, so
458
460
  the model sees the tools; the UIs keep showing the task meanwhile. The
459
461
  wait lasts at most `timeout` seconds from the task's start (default 60).
@@ -758,10 +760,12 @@ only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
758
760
  ## The mcp bundle
759
761
 
760
762
  `chi bundle install mcp` (or the `dev` profile) installs the bundle shipped with chi. It is written
761
- only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and adds
762
- tools from [MCP](https://modelcontextprotocol.io) servers. It has no memory
763
- file, so it costs the prompt nothing but its tools. Stdio servers only, for
764
- now.
763
+ only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and gives
764
+ the model the tools of [MCP](https://modelcontextprotocol.io) servers. It
765
+ has no memory file, and the servers' tools aren't declared to the model one
766
+ by one: it has two tools, `find_mcp_tools` and `mcp_call` (~250 tokens in
767
+ every request plus a line per server, whatever the servers have; `/mcp`
768
+ says how many). Stdio servers only, for now.
765
769
 
766
770
  ```yaml
767
771
  # config.yml
@@ -777,6 +781,7 @@ bundles:
777
781
  env: {NODE_OPTIONS: "--no-warnings"} # added to chi's environment
778
782
  cwd: ~/scratch # default: where chi runs
779
783
  tools: [read_*, list_directory] # optional: only these (globs)
784
+ description: files in ~/scratch # optional: its line in find_mcp_tools
780
785
  timeout: 120 # optional: this server's per-call timeout
781
786
  chrome:
782
787
  command: [npx, -y, "chrome-devtools-mcp@latest", --slim, --headless]
@@ -788,29 +793,33 @@ bundles:
788
793
  saved in the bundle's data dir (`$XDG_STATE_HOME/samagotchi/plugins/mcp/
789
794
  tools-<server>.json`), keyed by a digest of its `command`, `env` (names
790
795
  and values: only the digest is stored) and `cwd`. A session with a saved
791
- list registers the tools at once and **doesn't start the server**: the
796
+ list can search its tools at once and **doesn't start the server**: the
792
797
  first call of one of its tools does (the call's row shows the wait). So a
793
798
  session that never uses MCP spawns nothing, and a new chat opens without
794
799
  waiting for `npx`. If the live list differs from the saved one, the saved
795
- one is replaced, and so are the tools, from the next turn on.
800
+ one is replaced, and so is what a search finds. The model's tools never
801
+ change, so the prompt cache keeps. The saved file also keeps the first
802
+ sentence of the server's `instructions` (from `initialize`), for its line
803
+ in `find_mcp_tools`.
796
804
  - **The first run** (no saved list, or the config changed) starts the
797
805
  server in an [init task](#chiinitlabel-provides_tools-false-quiet-false-timeout-nil--ctx--):
798
806
  every UI shows `Starting MCP server x (first run, saving its tools)`, and
799
807
  a turn sent meanwhile waits for its tools. A server that doesn't start,
800
808
  answer or list its tools within `startup_timeout` (each step) is a warn
801
- card, `…: failed`, and its tools are left out. The rest of chi works as
802
- usual.
809
+ card, `…: failed`, and its calls fail until chi restarts. The rest of chi
810
+ works as usual.
803
811
  - **Freshness.** A saved list older than a day is still used, and a quiet
804
812
  background task lists the tools again with a server of its own (then
805
- stops it), saves them, and replaces the tools if they changed. One worker
806
- does it at a time.
813
+ stops it), saves them, and replaces the searched ones if they changed.
814
+ One worker does it at a time.
807
815
  - **A server that says its tools changed** (`notifications/tools/list_changed`)
808
- is asked for its `tools/list` again; the saved list and the tools are
809
- replaced from the next turn on.
816
+ is asked for its `tools/list` again; the saved list and the searched tools
817
+ are replaced.
810
818
  - **A cached server that doesn't start** (the command is gone, it crashes)
811
819
  fails that call with `Error: MCP server x didn't start: …` and one notice;
812
- later calls answer the same at once, and its tools are left out from the
813
- next turn. The saved list stays: the next session tries again.
820
+ later calls answer the same at once until chi restarts, and a search shows
821
+ its tools as `(failed: …)`. The saved list stays: the next session tries
822
+ again.
814
823
  - **`start: eager`** on a server starts it with every session (in an init
815
824
  task, after the Bridge is up), for a server whose start does something
816
825
  you want at once.
@@ -824,17 +833,45 @@ bundles:
824
833
  command: [chrome-devtools-mcp, --slim, --headless] # after npm i -g chrome-devtools-mcp
825
834
  ```
826
835
 
827
- - **Tools.** Each tool is the model's as `mcp_<server>_<tool>`, lower case,
828
- with anything but a-z, 0-9 and `_` made `_`, cut at 48 characters. A name
829
- that clashes is left out, with a notice. The tool's `inputSchema` is its
830
- schema (flattened on the native paths, see
831
- [Schemas on the native paths](#schemas-on-the-native-paths)); its label is
832
- `<server>: <tool>` and its preview the arguments, short.
836
+ - **Tools: search, then call.** The servers' tools are kept in an index;
837
+ the model has two fixed tools:
838
+ - `find_mcp_tools(query, server?)` searches it by keywords over the
839
+ server's name, the tool's name and its description, and answers up to 5
840
+ tools, each with its name `<server>/<tool>`, its description and its
841
+ whole `inputSchema`. A word counts more the fewer tools have it (a word
842
+ most of a browser server's tools share, "page", counts little), and
843
+ three times as much in a name as in a description. An empty query (and
844
+ a search with no match) lists every server's tool names, without
845
+ schemas. A tool whose `inputSchema` (or a property's schema) isn't an
846
+ object shows
847
+ `(bad schema)` (and is said once, as a notice), a failed server's tools
848
+ `(failed: …)`, a first-run server still starting `(starting, try
849
+ again)`. Its description names each server on one line, never its
850
+ tools: the server's `description:`, else the first sentence of its
851
+ `instructions`, else its first 6 tool names, and how many tools it has
852
+ (a first run's server is just its name until the next session).
853
+ - `mcp_call(tool, args)` calls one: `tool` is `<server>/<tool>`, and
854
+ `mcp_<server>_<tool>` works too (looked up, not parsed). Two tools whose
855
+ `mcp_<server>_<tool>` comes out the same (servers `git-hub` and
856
+ `git_hub`, names alike up to the 48-character cut) are both refused,
857
+ since guardrail rules and approvals couldn't tell them apart: a search
858
+ and `/mcp` mark them `(name clash with …)`, and there is one notice.
859
+ `args` are typed by the tool's
860
+ `inputSchema` (numbers and booleans given as text, JSON text for an
861
+ object). A name it doesn't know answers the closest ones (`Error: no
862
+ MCP tool chrome/open_url. Closest: chrome/new_page, …`). Its row shows
863
+ `mcp chrome/take_screenshot fullPage=true`.
864
+ - A tool turn takes a few more steps than when every tool was declared
865
+ (the P1 spike: about 1.5 more on a 30-tool server), and each request is
866
+ smaller (about half there).
833
867
  - **Calls.** A call is `tools/call`. The text blocks of the answer are joined;
834
868
  audio or a resource without text is a short placeholder
835
- (`[audio: audio/wav]`). `isError` makes it `Error: …`. A call that takes
836
- longer than the timeout is an `Error:`, and a cancelled turn stops the
837
- wait; both send `notifications/cancelled` to the server.
869
+ (`[audio: audio/wav]`). `isError` makes it `Error: …`; it, a JSON-RPC
870
+ error and `args` that aren't an object end with the tool's schema
871
+ (`chrome/click's inputSchema: {…}`), so a call made without a search can
872
+ be fixed on the next step. A call that takes longer than the timeout is
873
+ an `Error:`, and a cancelled turn stops the wait; both send
874
+ `notifications/cancelled` to the server.
838
875
  - **Images.** An `image` block goes to the model as a picture
839
876
  ([Returning images](#returning-images): at most 4 per call, a line
840
877
  instead when the model can't see images). Its place in the text is a line,
@@ -856,17 +893,20 @@ bundles:
856
893
  - **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
857
894
  closed, then TERM and KILL go to the server's process group.
858
895
  - **`/mcp`** (anytime) shows a card with the servers, their state (cached
859
- (not started), running with its pid, failed, stopped) and their tools.
896
+ (not started), running with its pid, failed with its count, stopped) and
897
+ their tools as `<server>/<tool>`, the name `mcp_call` takes.
860
898
  A cached or running server's line says roughly how many tokens its tool
861
- definitions take (`~4,232 tokens`), and the last line totals the servers
862
- whose tools the model has: what every request carries. It is an estimate:
899
+ definitions take (`~4,232 tokens`), which only a search's answer carries
900
+ now; the last line says what every request carries (`find_mcp_tools` and
901
+ `mcp_call`) and totals the servers'. It is an estimate:
863
902
  the definitions' JSON as the chat API gets it (`api: openai`), divided by
864
903
  4. The native prompts (Gemma, Qwen) render a flatter schema, so there they
865
904
  take less. The log records each server's estimate when it changes
866
905
  (`mcp_tools_estimated`).
867
906
  - The server's stderr goes to the debug log (`plugins` records, bundle=mcp).
868
907
  - **Guardrails.** A rule's `tool:` can be a glob, so one rule covers every
869
- MCP tool:
908
+ MCP tool (a search is `find_mcp_tools`, outside `mcp_*`, so it isn't
909
+ asked about):
870
910
 
871
911
  ```yaml
872
912
  guardrails:
@@ -877,10 +917,22 @@ bundles:
877
917
  reason: an MCP server's tool
878
918
  ```
879
919
 
880
- An MCP tool has no `targets:`, so the question shows its arguments,
881
- under the tool's label as its row shows it (`everything: get_sum: a=20
882
- b=22`; any plugin tool with a label is asked about by it), and "Allow this call for the
920
+ `mcp_call`'s `targets:` act as the tool it calls, by its
921
+ `mcp_<server>_<tool>` name ([acts_as:](#guardrails)), so `tool:
922
+ "mcp_github_*"` or `tool: mcp_github_merge_pull_request` match an
923
+ `mcp_call` to it, as they matched the tool when it was declared by that
924
+ name. A call made before the server's tools are known (a first run
925
+ still starting) is matched by the name the model gave, so the rule fires
926
+ before the call waits; if the tool it then finds isn't that one, the
927
+ call is refused. The question shows the inner arguments under `<server>: <tool>`
928
+ (`everything: get_sum: a=20 b=22`), and "Allow this call for the
883
929
  session" (or in this repo) allows that tool with those arguments only.
930
+ (Approvals stored before 0.6.0, under the old name, don't carry over.)
931
+ Two things see `mcp_call`, not the tool: hooks (a `before_tool_call`
932
+ event's `tool:` is `"mcp_call"`, the tool in its args), and loop-guard's
933
+ `ignore_tools` (`mcp_call` ignores every MCP call; calls to different
934
+ tools, or with different args, stay distinct, since the tool is in the
935
+ args).
884
936
 
885
937
  ## The loop-guard bundle
886
938
 
@@ -74,16 +74,29 @@ module Samagotchi
74
74
  end
75
75
  private_class_method :user_texts
76
76
 
77
+ # Both paths resolved first: a linked worktree's project root comes
78
+ # back resolved (/private/tmp/… on macOS) while the session's folder
79
+ # may go through a symlink (/tmp/…).
77
80
  def folder(cwd, project)
78
81
  return ContextNote.home_relative(cwd) if project.nil?
79
- return nil if cwd.to_s == project.to_s
80
82
 
81
- Pathname.new(cwd.to_s).relative_path_from(Pathname.new(project.to_s)).to_s
83
+ cwd = real(cwd)
84
+ project = real(project)
85
+ return nil if cwd == project
86
+
87
+ Pathname.new(cwd).relative_path_from(Pathname.new(project)).to_s
82
88
  rescue ArgumentError
83
89
  cwd
84
90
  end
85
91
  private_class_method :folder
86
92
 
93
+ def real(path)
94
+ File.realpath(path.to_s)
95
+ rescue SystemCallError
96
+ File.expand_path(path.to_s)
97
+ end
98
+ private_class_method :real
99
+
87
100
  def cut(text, limit)
88
101
  line = text.to_s.gsub(/\s+/, " ").strip
89
102
  line.length > limit ? "#{line[0, limit - 1]}…" : line
@@ -46,7 +46,8 @@ module Samagotchi
46
46
  DEFAULT_PARALLEL = 4
47
47
  DEFAULT_DEADLINE = 20.0
48
48
  DEFAULT_THRESHOLD = 0.5
49
- # How long threads still asking get to stop once the deadline passed.
49
+ # How long the threads still asking get to stop once the deadline
50
+ # passed, all of them together (then they are killed).
50
51
  JOIN_GRACE = 1.0
51
52
 
52
53
  module_function
@@ -158,7 +159,8 @@ module Samagotchi
158
159
  cancel.cancel!(:deadline)
159
160
  queue.clear
160
161
  end
161
- threads.each { |thread| thread.join(JOIN_GRACE) || thread.kill }
162
+ grace_ends = clock.call + JOIN_GRACE
163
+ threads.each { |thread| thread.join([grace_ends - clock.call, 0].max) || thread.kill }
162
164
  cards.to_h { |card| [card.id, results[card.id] || unchecked("triage deadline")] }
163
165
  end
164
166
 
@@ -182,7 +184,9 @@ module Samagotchi
182
184
  TOP_LOGPROBS = 5
183
185
  LOGPROB_OPTIONS = { logprobs: true, top_logprobs: TOP_LOGPROBS }.freeze
184
186
  # Hosts (base urls) that refused logprobs with a 400: asked without
185
- # them from then on, in this process.
187
+ # them from then on, in this process. Shared by every thread's LLM:
188
+ # a parallel batch's first requests may each meet the 400 once, the
189
+ # requests after them don't.
186
190
  @no_logprobs = Set.new
187
191
  @no_logprobs_lock = Mutex.new
188
192
 
@@ -247,20 +251,33 @@ module Samagotchi
247
251
 
248
252
  def ask(messages)
249
253
  base_url = @target.base_url
250
- options = self.class.logprobs?(base_url) ? LOGPROB_OPTIONS : {}
254
+ return request(messages, {}) unless self.class.logprobs?(base_url)
255
+
251
256
  begin
252
- @client.ask(messages, max_tokens: MAX_TOKENS, cancel_controller: @cancel, kind: "broadcast", options: options)
257
+ request(messages, LOGPROB_OPTIONS)
253
258
  rescue IdleClient::SummarizeError => e
254
- raise unless !options.empty? && e.cause.is_a?(Samagotchi::LLM::BadRequest)
259
+ raise unless e.cause.is_a?(Samagotchi::LLM::BadRequest)
255
260
 
256
- # The host won't give logprobs (a server that refuses "n and
257
- # logprobs"): ask again without, and leave them out from now on.
258
- self.class.no_logprobs!(base_url)
259
- Log.info(:broadcast, "logprobs_refused", model: @target.label, detail: e.cause.message.to_s[0, 200])
260
- options = {}
261
- retry
261
+ # The host may not give logprobs (a server that refuses "n and
262
+ # logprobs"): ask again without. Leave them out from now on when
263
+ # the 400 said so or the retry answered; a 400 the retry gets
264
+ # too was about something else.
265
+ refused = e.cause.message.to_s.match?(/logprob/i)
266
+ no_logprobs!(e.cause) if refused
267
+ answer = request(messages, {})
268
+ no_logprobs!(e.cause) unless refused
269
+ answer
262
270
  end
263
271
  end
272
+
273
+ def no_logprobs!(error)
274
+ self.class.no_logprobs!(@target.base_url)
275
+ Log.info(:broadcast, "logprobs_refused", model: @target.label, detail: error.message.to_s[0, 200])
276
+ end
277
+
278
+ def request(messages, options)
279
+ @client.ask(messages, max_tokens: MAX_TOKENS, cancel_controller: @cancel, kind: "broadcast", options: options)
280
+ end
264
281
  end
265
282
  end
266
283
  end
@@ -119,12 +119,17 @@ module Samagotchi
119
119
 
120
120
  def rotated_path = "#{@path}.1"
121
121
 
122
+ # Appends a line under log.jsonl.lock, held from the size check to
123
+ # the write, so two broadcasts at once rotate the file once (the
124
+ # file itself can't be the lock: a rename moves it).
122
125
  def append(data)
123
126
  FileUtils.mkdir_p(File.dirname(@path))
124
- File.rename(@path, rotated_path) if File.file?(@path) && File.size(@path) > MAX_BYTES
125
- File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o600) do |file|
126
- file.flock(File::LOCK_EX)
127
- file.write("#{JSON.generate(data)}\n")
127
+ File.open("#{@path}.lock", File::WRONLY | File::CREAT, 0o600) do |lock|
128
+ lock.flock(File::LOCK_EX)
129
+ File.rename(@path, rotated_path) if File.file?(@path) && File.size(@path) > MAX_BYTES
130
+ File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o600) do |file|
131
+ file.write("#{JSON.generate(data)}\n")
132
+ end
128
133
  end
129
134
  end
130
135
 
@@ -388,12 +388,14 @@ module Samagotchi
388
388
  def line_for(id, word, why) = "#{id[0, 8]} #{word.ljust(9)} #{why}"
389
389
 
390
390
  # " · 2 unchecked: triage deadline" when some were delivered without a
391
- # verdict (the desktop helper shows only this line), else "".
391
+ # verdict (the desktop helper shows only this line), else "". Each
392
+ # reason ends before its details: a parenthesis, a quote, or a single
393
+ # quote after a space ("host_ref 'lan' is …", not "isn't").
392
394
  def unchecked_summary(decisions)
393
395
  unchecked = decisions.map(&:verdict).select(&:unchecked?)
394
396
  return "" if unchecked.empty?
395
397
 
396
- whys = unchecked.map { |v| v.reason.delete_prefix("unchecked: ").sub(/\s*[("].*\z/m, "") }.uniq
398
+ whys = unchecked.map { |v| v.reason.delete_prefix("unchecked: ").sub(/(?:\s*[("]|\s+').*\z/m, "") }.uniq
397
399
  " · #{unchecked.size} unchecked: #{whys.join(", ")}"
398
400
  end
399
401
 
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.5.1
3
+ version: 0.6.0
4
4
  scope: system
5
- description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
5
+ description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; the model searches their tools with find_mcp_tools and calls one with mcp_call (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:e41b880d70d2574f2be7d64e3ddee209ec77ce203f77d347ccdc6f9df1b358e1
11
- requires_chi: ">= 0.33.0"
10
+ sha256: sha256:561dfc3a765d6a72913a7bc416ac0120e14f5747fdfc8f692e3d4dfb241ff6a6
11
+ # 0.37.0 (published 2026-10-07) is the release whose targets: take acts_as:, args: and label:.
12
+ requires_chi: ">= 0.37.0"