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 +4 -4
- data/CHANGELOG.md +39 -1
- data/README.md +1 -1
- data/docs/broadcast.md +5 -2
- data/docs/configuration.md +1 -1
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +84 -32
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +29 -12
- data/lib/samagotchi/broadcast/triage_log.rb +9 -4
- data/lib/samagotchi/broadcast_command.rb +4 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +424 -89
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
- data/lib/samagotchi/desktop/macos.rb +27 -5
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/plugin/api.rb +5 -3
- data/lib/samagotchi/tool_response.rb +5 -5
- data/lib/samagotchi/tool_runner.rb +18 -9
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 35846da55e0107127da528067ad179a59cbd7a23eee66c1b770a17d1504c717b
|
|
4
|
+
data.tar.gz: e28ce71127da751668251ddfca44dc8b05a97e038f5d03045357f1cdbac26f20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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 |
|
|
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
|
|
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 (`
|
|
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
|
|
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
|
|
data/docs/internals/engine.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
456
|
-
`chi.replace_tools`)
|
|
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
|
|
762
|
-
tools
|
|
763
|
-
file,
|
|
764
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
809
|
-
replaced
|
|
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
|
|
813
|
-
|
|
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
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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:
|
|
836
|
-
|
|
837
|
-
|
|
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
|
|
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`),
|
|
862
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
254
|
+
return request(messages, {}) unless self.class.logprobs?(base_url)
|
|
255
|
+
|
|
251
256
|
begin
|
|
252
|
-
|
|
257
|
+
request(messages, LOGPROB_OPTIONS)
|
|
253
258
|
rescue IdleClient::SummarizeError => e
|
|
254
|
-
raise unless
|
|
259
|
+
raise unless e.cause.is_a?(Samagotchi::LLM::BadRequest)
|
|
255
260
|
|
|
256
|
-
# The host
|
|
257
|
-
# logprobs"): ask again without
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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:
|
|
11
|
-
|
|
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"
|