samagotchi 0.13.0 → 0.14.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 +55 -1
- data/bin/chi +4 -7
- data/docs/configuration.md +50 -7
- data/docs/guardrails.md +2 -1
- data/docs/plugins.md +27 -12
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +31 -6
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/engine.rb +14 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
- data/lib/samagotchi/host_registry.rb +31 -6
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/session_manager.rb +6 -0
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +37 -10
- data/lib/samagotchi/terminal_ui.rb +16 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -10
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +18 -7
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +39 -6
- data/lib/samagotchi/web/public/index.html +8 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +37 -0
- data/lib/samagotchi/web/public/turn_view.js +6 -9
- 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: 760138c7792d414a15c39e074c5a6dc0ad44dfd0937c12707c3e6428a3544d43
|
|
4
|
+
data.tar.gz: d347cc3e3d7433292c40484beb73b95e2e3a9efb48ecd5ba5168b3c562c8d7e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae3585c68672ad0b163448b92c83e9f2d60dbc5efd60c47090170d80750ad20a08a5eae97aebf0dbc57629909db228de565a6a3a00ccb40bff9757ea66c3aeff
|
|
7
|
+
data.tar.gz: 4330ef708fe3ebc22f30e4cf249f88fb68a870eb1afd3be9894822e8e819ca25d393b34ffbabf56ef23ee784016b7cdcae9014d709f42db31fac88f501048ddb
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,59 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.14.0] - 2026-10-02
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The web marks a session that waits for you: its card gets a warn border, a pulsing dot and a `question`,
|
|
16
|
+
`approval` or `needs you` badge, and it moves to the top of the strip and the all-sessions view until answered
|
|
17
|
+
(the order holds while the pointer is over the list).
|
|
18
|
+
- `models.<key>.window_tokens` and `hosts.<name>.window_tokens` in config.yml set a model's or a host's context window
|
|
19
|
+
for when the server and its model list report none (the model's wins over the host's, and both over
|
|
20
|
+
`context.window_tokens`); the server's `n_ctx` still wins over all of them.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- chi starts under `LC_ALL=C` (or another US-ASCII locale): `chi -p` crashed with `Encoding::CompatibilityError` while
|
|
25
|
+
building the system prompt from a memory index with non-ASCII text. chi and its workers read their files as UTF-8
|
|
26
|
+
whatever the locale.
|
|
27
|
+
- The `mcp` bundle (0.4.0, `chi update`) attaches an image an MCP tool names inside its text
|
|
28
|
+
(`Saved screenshot to /tmp/shot.png.`), not only a text that is the bare path; the same checks apply (an image by
|
|
29
|
+
its bytes, under the temp dir or the server's `cwd`).
|
|
30
|
+
- An MCP server that sends `notifications/tools/list_changed` has its tools listed again (`mcp` 0.4.0): its saved list
|
|
31
|
+
and the model's tools are replaced from the next turn on. They were only logged.
|
|
32
|
+
- An MCP server that exits mid-session starts again on its next call (`mcp` 0.4.0), up to 3 times a session; its tools
|
|
33
|
+
failed until chi restarted.
|
|
34
|
+
- A burst of plugin notices no longer pushes cards and questions out of what a reloaded web page or a joining
|
|
35
|
+
`chi --attach` shows: the worker keeps the last 20 of each apart, and never drops a question still waiting for its
|
|
36
|
+
answer.
|
|
37
|
+
- `/checkin off`, `/checkin mode …` and `/checkin 30` last for the session across a worker's restart (an idle exit,
|
|
38
|
+
`chi send` waking it): the `check-in` bundle (0.2.0, `chi update`) saves them per session and reads them back.
|
|
39
|
+
- `printf '2\n' | chi --attach ID` on a session already waiting on a question answers it: the piped line could be read
|
|
40
|
+
before the question arrived and go in as a new prompt, and the attach then hung.
|
|
41
|
+
- A REPL reading its answers from a pipe shows each answer line once (`? 2`), not `? ? 2`, above why it was refused
|
|
42
|
+
or invalid.
|
|
43
|
+
- A plugin's cards and a turn's hook notices (loop-guard's "stopped the turn" card and its `loop: … denied` rows,
|
|
44
|
+
check-in's, any bundle's) stay after the worker goes: the worker saves them in the session's folder
|
|
45
|
+
(`cards.json`, the same last-20 caps), the web shows them in place for a stopped session, and a later worker starts
|
|
46
|
+
from them. They used to vanish with an idle exit, a restart or `chi sessions stop`.
|
|
47
|
+
- The web marks a tool call a guardrail denied (loop-guard, the `guardrails` bundle, a parent's or the user's Deny)
|
|
48
|
+
`blocked` in red, as the TUI does, live and after a reload; it showed a green `done`.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- A host is remote by its address, not by its API key: an `https` url or a public IP address. A llama.cpp on the LAN
|
|
53
|
+
started with `--api-key` (`api_key_env:`) is now local again: chi asks its `/props` for the context window and the
|
|
54
|
+
served model, keeps its model list for 60 s, and gives it no first-token limit. `hosts.<name>.remote: true|false`
|
|
55
|
+
decides it for a host chi gets wrong (an http host named by a public DNS name counts as local).
|
|
56
|
+
- Codex CLI counts as a parent agent: an answer typed into `chi --attach` or the REPL from a command Codex runs
|
|
57
|
+
(`CODEX_THREAD_ID` set) is held to `guardrails.parent_approvals`, as one from Claude Code is.
|
|
58
|
+
- `delegate` and `delegate_result` report a child's wait with the status words of `chi send --wait --format json`:
|
|
59
|
+
`answered` (was `done`), `question` (was `waiting_for_answer`), `failed`, `canceled` or `no_answer` (was `no_reply`),
|
|
60
|
+
and `running` when the parent's turn is canceled (was `canceled`).
|
|
61
|
+
|
|
62
|
+
Update with `chi update` (bundles: mcp 0.4.0, check-in 0.2.0).
|
|
63
|
+
|
|
11
64
|
## [0.13.0] - 2026-10-02
|
|
12
65
|
|
|
13
66
|
### Added
|
|
@@ -729,7 +782,8 @@ and long-lived sessions.
|
|
|
729
782
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
730
783
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
731
784
|
|
|
732
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
785
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.14.0...HEAD
|
|
786
|
+
[0.14.0]: https://github.com/dm1try/samagotchi/compare/v0.13.0...v0.14.0
|
|
733
787
|
[0.13.0]: https://github.com/dm1try/samagotchi/compare/v0.12.0...v0.13.0
|
|
734
788
|
[0.12.0]: https://github.com/dm1try/samagotchi/compare/v0.11.0...v0.12.0
|
|
735
789
|
[0.11.0]: https://github.com/dm1try/samagotchi/compare/v0.10.1...v0.11.0
|
data/bin/chi
CHANGED
|
@@ -3,13 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
5
5
|
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
ENV["LANG"] = "en_US.UTF-8"
|
|
11
|
-
Encoding.default_external = Encoding::UTF_8
|
|
12
|
-
end
|
|
6
|
+
# LC_ALL=C or no locale at all: files would read as US-ASCII, and chi's
|
|
7
|
+
# memories and sessions are UTF-8 (Utf8Default).
|
|
8
|
+
require "samagotchi/utf8_default"
|
|
9
|
+
Samagotchi::Utf8Default.apply!
|
|
13
10
|
|
|
14
11
|
require "optparse"
|
|
15
12
|
require "samagotchi/config"
|
data/docs/configuration.md
CHANGED
|
@@ -232,6 +232,25 @@ hosts:
|
|
|
232
232
|
api_key_env: BOX_LLAMA_KEY
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
+
**Remote or local.** chi treats a host as a remote provider by its address, not
|
|
236
|
+
by its key: an `https` url, or an `http` IP address outside the loopback,
|
|
237
|
+
private (`10.*`, `172.16–31.*`, `192.168.*`, IPv6 `fc00::/7`), link-local and
|
|
238
|
+
CGNAT/Tailscale (`100.64.0.0/10`) nets. An `http` host given by a name
|
|
239
|
+
(`localhost`, `box`, `mac.local`) is local: chi doesn't look names up. A remote
|
|
240
|
+
host keeps its model list for 10 minutes, gets a 120-second first-token limit,
|
|
241
|
+
and isn't asked for llama.cpp's `/props` (its context window and served model
|
|
242
|
+
come from its model list and each turn). `remote: true` or `remote: false` on a
|
|
243
|
+
host decides it instead, e.g. for a llama.cpp behind an https proxy on the LAN:
|
|
244
|
+
|
|
245
|
+
```yaml
|
|
246
|
+
hosts:
|
|
247
|
+
lab:
|
|
248
|
+
url: https://llm.lab.example/v1
|
|
249
|
+
api: openai
|
|
250
|
+
api_key_env: LAB_KEY
|
|
251
|
+
remote: false
|
|
252
|
+
```
|
|
253
|
+
|
|
235
254
|
A host without `port:` (and without `url:`) uses port 8080. `enabled: false`
|
|
236
255
|
turns an entry off without deleting it: chi skips it as if it weren't there
|
|
237
256
|
(not in `chi models`, never the default host; `box:model` then counts as an
|
|
@@ -481,7 +500,7 @@ hosts:
|
|
|
481
500
|
```
|
|
482
501
|
|
|
483
502
|
`hosts.<name>.first_token_timeout` wins over `server.first_token_timeout` (`SAMAGOTCHI_SERVER_FIRST_TOKEN_TIMEOUT`),
|
|
484
|
-
which applies to every host. With neither set, remote hosts (
|
|
503
|
+
which applies to every host. With neither set, remote hosts (see "Remote or local" above) get 120 seconds and local
|
|
485
504
|
servers no limit: a long prompt evaluation is normal there, and the read timeout catches a dead server.
|
|
486
505
|
|
|
487
506
|
Every chat request carries the session's id as a `Session-Id` header (next to `User-Agent: chi/<version>`). A
|
|
@@ -507,11 +526,33 @@ to the default host. Only exact ids count, never a substring: `/model gemma` on
|
|
|
507
526
|
a box that lists `gemma-3-12b` needs `box:gemma` or the exact id. The lists are
|
|
508
527
|
known only after `/models` ran (nothing is fetched before the first turn), so
|
|
509
528
|
until then an unqualified name goes to the default host; use `host:model` to pin
|
|
510
|
-
one. A **remote** host (
|
|
511
|
-
list for 10 minutes (60s for local hosts).
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
529
|
+
one. A **remote** host (an `https` url or a public address; see "Remote or local") keeps its model
|
|
530
|
+
list for 10 minutes (60s for local hosts).
|
|
531
|
+
|
|
532
|
+
**The context window** (what the context status and `ctx=` count against) comes from,
|
|
533
|
+
first to last:
|
|
534
|
+
|
|
535
|
+
1. the running server (llama.cpp's `/props` `n_ctx`; a remote host isn't asked),
|
|
536
|
+
2. the window the host's model list gives (`context_length`, `context_window`,
|
|
537
|
+
`max_model_len` or llama.cpp's `meta.n_ctx`),
|
|
538
|
+
3. `models.<key>.window_tokens` (the key as `sampling:` looks a model up), else
|
|
539
|
+
`hosts.<name>.window_tokens`,
|
|
540
|
+
4. `context.window_tokens`, else 256000.
|
|
541
|
+
|
|
542
|
+
The settings only fill in when the server and its list report nothing: what the
|
|
543
|
+
server runs with is the window that counts. Set one for a provider whose list has
|
|
544
|
+
no window:
|
|
545
|
+
|
|
546
|
+
```yaml
|
|
547
|
+
hosts:
|
|
548
|
+
work:
|
|
549
|
+
url: https://llm.work.example/v1
|
|
550
|
+
api: openai
|
|
551
|
+
window_tokens: 131072
|
|
552
|
+
models:
|
|
553
|
+
qwen3-8b:
|
|
554
|
+
window_tokens: 32768
|
|
555
|
+
```
|
|
515
556
|
|
|
516
557
|
Only `:` names a host: `/` is part of a model id (`openai/gpt-4o` is an
|
|
517
558
|
OpenRouter id and goes to the default host as written, even with a host named
|
|
@@ -812,13 +853,15 @@ described in their own sections.
|
|
|
812
853
|
| `log.level` | `info` | yes | `debug`, `info`, `warn`, `error`. |
|
|
813
854
|
| `status.line` | `on` | yes | The status row under the prompt (the REPL's and attached mode's), `on` or `off`. |
|
|
814
855
|
| `context.status` | `true` | yes | Context-usage telemetry for the model. See [context telemetry](internals/context-telemetry.md). |
|
|
815
|
-
| `context.window_tokens` | server's, else 256000 | yes | Context window when the server doesn't report one. |
|
|
856
|
+
| `context.window_tokens` | server's, else 256000 | yes | Context window when the server doesn't report one. `models.<key>.window_tokens` and `hosts.<name>.window_tokens` come first; see "The context window". |
|
|
857
|
+
| `models.<key>.window_tokens`, `hosts.<name>.window_tokens` | none | | A model's or host's context window when the server and its model list report none. |
|
|
816
858
|
| `context.chars_per_token` | `4.0` | yes | Estimate ratio when the server reports no usage. |
|
|
817
859
|
| `context.status_thresholds` | `20,40,60,80` | yes | Percentages that trigger a status. |
|
|
818
860
|
| `context.status_cadence` | `0` | yes | Also every N rounds; `0` = thresholds only. |
|
|
819
861
|
| `thinking.turn_preamble` | `true` | yes | Ask a `qwen36` model to open its thinking with a short `TURN:` line (the step label). |
|
|
820
862
|
| `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". |
|
|
821
863
|
| `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
|
|
864
|
+
| `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
|
|
822
865
|
| `max_tool_output_chars` | `10000` | yes | Tool output kept in the conversation; a top-level key (see below). |
|
|
823
866
|
| `retry.max` | `5` | yes | See "Llama Network Retry Behavior". |
|
|
824
867
|
| `retry.base_delay` | `0.5` | yes | |
|
data/docs/guardrails.md
CHANGED
|
@@ -130,7 +130,8 @@ scope.
|
|
|
130
130
|
A parent may also type its answer into chi itself: a piped `chi --attach ID`
|
|
131
131
|
or `printf '2\n' | chi --no-shared -p …`. So chi treats an answer as a
|
|
132
132
|
parent's, held to the same setting, when its input isn't a terminal or an
|
|
133
|
-
agent marker is set: `CLAUDECODE
|
|
133
|
+
agent marker is set: `CLAUDECODE` (Claude Code), `AI_AGENT`, `CODEX_THREAD_ID`
|
|
134
|
+
(Codex CLI), or `SAMAGOTCHI_PARENT_SESSION`,
|
|
134
135
|
which chi's own `execute` and `task_create` export (the session's id) into the
|
|
135
136
|
commands they run. The markers survive a PTY wrapper (`script`, `expect`). A
|
|
136
137
|
person at a terminal with no marker keeps every scope.
|
data/docs/plugins.md
CHANGED
|
@@ -544,12 +544,16 @@ completed turn stays in its step.
|
|
|
544
544
|
An [anytime command](#anytime-true)'s cards show as it shows them, after its
|
|
545
545
|
line, in every UI, whether a turn runs or not.
|
|
546
546
|
|
|
547
|
-
A worker keeps its last 20 cards
|
|
548
|
-
|
|
547
|
+
A worker keeps its last 20 cards (questions included) and, apart, its
|
|
548
|
+
last 20 hook notices, for a UI that joins later; a question still waiting
|
|
549
|
+
for its answer is always kept. The web shows them where they arrived after a reload (a turn's
|
|
549
550
|
notice as a row of its step, above the call it came before); the attached
|
|
550
551
|
TUI shows the cards and between-turns notices since the last turn when it
|
|
551
|
-
joins.
|
|
552
|
-
|
|
552
|
+
joins. The worker saves them in the session's folder (`cards.json`, the same
|
|
553
|
+
caps), so they outlive it: the web shows them in place for a stopped
|
|
554
|
+
session, and the next worker (an idle exit, a restart, `chi sessions stop`
|
|
555
|
+
then a new turn) starts from them. Load warnings and a question still
|
|
556
|
+
waiting when the worker went are not saved.
|
|
553
557
|
|
|
554
558
|
The event is `{type: :card, id:, source:, title:, body:, level:, actions:,
|
|
555
559
|
in_turn:}` (`source` is the bundle), logged as `card` with its source, id and
|
|
@@ -690,6 +694,9 @@ bundles:
|
|
|
690
694
|
background task lists the tools again with a server of its own (then
|
|
691
695
|
stops it), saves them, and replaces the tools if they changed. One worker
|
|
692
696
|
does it at a time.
|
|
697
|
+
- **A server that says its tools changed** (`notifications/tools/list_changed`)
|
|
698
|
+
is asked for its `tools/list` again; the saved list and the tools are
|
|
699
|
+
replaced from the next turn on.
|
|
693
700
|
- **A cached server that doesn't start** (the command is gone, it crashes)
|
|
694
701
|
fails that call with `Error: MCP server x didn't start: …` and one notice;
|
|
695
702
|
later calls answer the same at once, and its tools are left out from the
|
|
@@ -722,15 +729,20 @@ bundles:
|
|
|
722
729
|
([Returning images](#returning-images): at most 4 per call, a line
|
|
723
730
|
instead when the model can't see images). Its place in the text is a line,
|
|
724
731
|
`[image 1: image/png, attached]`, so the model knows the order. A text
|
|
725
|
-
block that is **
|
|
726
|
-
(`[image 1: screenshot.png, attached]` after the
|
|
727
|
-
|
|
728
|
-
|
|
732
|
+
block that is **the absolute path of an image file** is attached too
|
|
733
|
+
(`[image 1: screenshot.png, attached]` after the text), and so is an
|
|
734
|
+
absolute path ending in `.png`, `.jpg`, `.jpeg`, `.gif` or `.webp` inside
|
|
735
|
+
a sentence (`Saved screenshot to /tmp/shot.png.`; such a path can't hold a
|
|
736
|
+
space). Either way only when the file is an image by its bytes and is
|
|
737
|
+
under the system temp dir or the server's `cwd`: a server's text can't
|
|
738
|
+
pull in any image on disk. `chrome-devtools-mcp --slim` answers
|
|
729
739
|
`screenshot` that way; without `--slim`, `take_screenshot` returns an image
|
|
730
740
|
block. `attach_image_paths: false` on a server leaves such paths as text.
|
|
731
|
-
- **A server that exits** fails
|
|
732
|
-
running (…)`, and there is one notice.
|
|
733
|
-
|
|
741
|
+
- **A server that exits** fails the call it was on with `Error: MCP server
|
|
742
|
+
x is not running (…)`, and there is one notice. Its next call starts it
|
|
743
|
+
again (its tools stay), up to 3 times a session; after the third restart
|
|
744
|
+
the notice says so, and its calls fail at once until chi restarts (a new
|
|
745
|
+
session, or the worker's next start).
|
|
734
746
|
- **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
|
|
735
747
|
closed, then TERM and KILL go to the server's process group.
|
|
736
748
|
- **`/mcp`** (anytime) shows a card with the servers, their state (cached
|
|
@@ -923,7 +935,10 @@ bundles:
|
|
|
923
935
|
and what is left, not "how's it going?": a small model tends to answer that
|
|
924
936
|
with a line and carry on unchanged.
|
|
925
937
|
|
|
926
|
-
`/checkin` (anytime) for this session
|
|
938
|
+
`/checkin` (anytime) for this session. What it changes (on/off, the mode,
|
|
939
|
+
the threshold) is saved with the session, in the bundle's data dir
|
|
940
|
+
(`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart;
|
|
941
|
+
another session starts from config.yml:
|
|
927
942
|
|
|
928
943
|
| | |
|
|
929
944
|
|---|---|
|
data/docs/sessions.md
CHANGED
|
@@ -180,7 +180,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
180
180
|
|
|
181
181
|
- `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
|
|
182
182
|
- `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
|
|
183
|
-
- What a wait returns, `session: <id>` then `status:` and the text: `
|
|
183
|
+
- What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question or a guardrail approval, with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
|
|
184
184
|
- Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
|
|
185
185
|
- Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
|
|
186
186
|
- The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it; `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
|
data/docs/sub-agent.md
CHANGED
|
@@ -66,7 +66,7 @@ and neither does any allow on chi's own config, hooks or guardrail rules. See
|
|
|
66
66
|
|
|
67
67
|
An answer typed into `chi --attach` or the REPL counts as a parent's too when
|
|
68
68
|
stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
|
|
69
|
-
`SAMAGOTCHI_PARENT_SESSION`) is set.
|
|
69
|
+
`CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
|
|
70
70
|
|
|
71
71
|
The worker checks it again with its own config.yml. The guardrails settings
|
|
72
72
|
have no environment variables, and a worker drops any `SAMAGOTCHI_GUARDRAILS_*`
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../atomic_file"
|
|
3
6
|
require_relative "../events"
|
|
4
7
|
|
|
5
8
|
module Samagotchi
|
|
@@ -20,32 +23,70 @@ module Samagotchi
|
|
|
20
23
|
# A card with an earlier card's id replaces it where it was. Each entry
|
|
21
24
|
# says where it belongs as the recap does: +turns_since+, the turns
|
|
22
25
|
# completed after it (after its own turn for a card shown during one),
|
|
23
|
-
# and +current+ for a card of the turn running now.
|
|
24
|
-
# this worker's start; the ones before it are in no entry. A card that
|
|
26
|
+
# and +current+ for a card of the turn running now. A card that
|
|
25
27
|
# isn't the turn's but comes while one runs (an anytime command's, such
|
|
26
28
|
# as /btw) goes after that turn: its prompt is already in the history.
|
|
27
29
|
# A failed turn leaves no prompt, so its cards stay before the next one.
|
|
30
|
+
#
|
|
31
|
+
# Notices (hook notices, load warnings, "asking again" rows) and cards
|
|
32
|
+
# (with questions) are capped apart, +capacity+ each, so a burst of
|
|
33
|
+
# notices can't push out a card. An open question (an approval too) is
|
|
34
|
+
# never pushed out: the oldest other card or answered question goes.
|
|
35
|
+
#
|
|
36
|
+
# With a +path+ (the Bridge's: FILE in the session's folder) the store
|
|
37
|
+
# saves its entries and turn count there on each change, and a later
|
|
38
|
+
# worker's store starts from them, so a turn's cards and notices outlive
|
|
39
|
+
# the worker that showed them; the web reads the file (.saved) for a
|
|
40
|
+
# session no worker runs. Turns count on from the saved count: the
|
|
41
|
+
# placement is relative (turns since), so only the turns after an entry
|
|
42
|
+
# matter. The load warnings stay the worker's own (each start announces
|
|
43
|
+
# its own), and a question still open when the worker went waits for no
|
|
44
|
+
# one: neither is saved or seeded. Same caps as in memory. An entry from
|
|
45
|
+
# an earlier worker lists with +earlier+: true (an attached TUI's resync
|
|
46
|
+
# onto a new worker doesn't print those again).
|
|
28
47
|
class CardStore
|
|
29
48
|
CAPACITY = 20
|
|
49
|
+
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry].freeze
|
|
50
|
+
FILE = "cards.json"
|
|
51
|
+
# The events after which the file is written again: the ones that
|
|
52
|
+
# change an entry or the turn count.
|
|
53
|
+
SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
|
|
54
|
+
turn_failed] + Events::TURN_KEPT).freeze
|
|
55
|
+
|
|
56
|
+
# What a session's saved file lists, as a store started from it lists
|
|
57
|
+
# (no turn running): the web's cards for a session no worker runs.
|
|
58
|
+
# @param session_dir [String]
|
|
59
|
+
# @return [Array<Hash>] [] without a readable file
|
|
60
|
+
def self.saved(session_dir)
|
|
61
|
+
new(path: File.join(session_dir, FILE)).list
|
|
62
|
+
end
|
|
30
63
|
|
|
31
|
-
|
|
64
|
+
# @param path [String, nil] the file the entries are saved in and
|
|
65
|
+
# started from; nil keeps them in memory only
|
|
66
|
+
def initialize(capacity: CAPACITY, path: nil)
|
|
32
67
|
@capacity = capacity
|
|
68
|
+
@path = path
|
|
33
69
|
@mutex = Mutex.new
|
|
34
70
|
@entries = []
|
|
35
71
|
@turns_done = 0
|
|
36
72
|
@running = false
|
|
37
73
|
@iteration = nil
|
|
38
74
|
@calls = 0
|
|
75
|
+
seed
|
|
39
76
|
end
|
|
40
77
|
|
|
41
78
|
def call(event)
|
|
42
|
-
@mutex.synchronize
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
fold(event)
|
|
81
|
+
save if SAVED_ON.include?(event[:type])
|
|
82
|
+
end
|
|
43
83
|
rescue StandardError
|
|
44
84
|
nil # never break the running turn
|
|
45
85
|
end
|
|
46
86
|
|
|
47
87
|
# @return [Array<Hash>] oldest first: the card's (or notice's) fields
|
|
48
|
-
# plus turns_since: and current:,
|
|
88
|
+
# plus turns_since: and current:, earlier: true for one an earlier
|
|
89
|
+
# worker saved, and during: true for a card that
|
|
49
90
|
# came while the running turn runs but isn't that turn's (it goes
|
|
50
91
|
# after the running turn's prompt)
|
|
51
92
|
def list
|
|
@@ -63,6 +104,42 @@ module Samagotchi
|
|
|
63
104
|
|
|
64
105
|
private
|
|
65
106
|
|
|
107
|
+
def save
|
|
108
|
+
return unless @path
|
|
109
|
+
|
|
110
|
+
entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
|
|
111
|
+
# A session that never showed one gets no file (the turn count
|
|
112
|
+
# only places entries relative to each other).
|
|
113
|
+
return if entries.empty? && !File.exist?(@path)
|
|
114
|
+
|
|
115
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
116
|
+
AtomicFile.write(@path, JSON.generate({ "version" => 1, "turns" => @turns_done, "entries" => entries }))
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil # a card that can't be saved still shows live
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The entries an earlier worker saved (a missing or broken file:
|
|
122
|
+
# none), their types as symbols again; a card that came during that
|
|
123
|
+
# worker's last turn stays where it was, as after a failed turn.
|
|
124
|
+
def seed
|
|
125
|
+
return unless @path && File.file?(@path)
|
|
126
|
+
|
|
127
|
+
data = JSON.parse(File.read(@path), symbolize_names: true)
|
|
128
|
+
return unless data.is_a?(Hash) && data[:turns].is_a?(Integer) && data[:entries].is_a?(Array)
|
|
129
|
+
|
|
130
|
+
@turns_done = data[:turns]
|
|
131
|
+
@entries = data[:entries].filter_map do |entry|
|
|
132
|
+
next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
|
|
133
|
+
|
|
134
|
+
entry = entry.merge(type: entry[:type].to_sym, earlier: true)
|
|
135
|
+
entry unless entry[:type] == :guardrail_warning || open_question?(entry)
|
|
136
|
+
end
|
|
137
|
+
settle_during(after_turn: false)
|
|
138
|
+
rescue StandardError
|
|
139
|
+
@turns_done = 0
|
|
140
|
+
@entries = []
|
|
141
|
+
end
|
|
142
|
+
|
|
66
143
|
def fold(event)
|
|
67
144
|
case event[:type]
|
|
68
145
|
when :turn_started
|
|
@@ -144,7 +221,18 @@ module Samagotchi
|
|
|
144
221
|
|
|
145
222
|
def push(entry)
|
|
146
223
|
@entries << entry
|
|
147
|
-
|
|
224
|
+
notice = notice?(entry)
|
|
225
|
+
kind = @entries.select { |e| notice?(e) == notice }
|
|
226
|
+
return if kind.size <= @capacity
|
|
227
|
+
|
|
228
|
+
evict = kind.find { |e| !open_question?(e) }
|
|
229
|
+
@entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def notice?(entry) = NOTICE_TYPES.include?(entry[:type])
|
|
233
|
+
|
|
234
|
+
def open_question?(entry)
|
|
235
|
+
entry[:type] == :question && !entry.key?(:answer) && !entry[:cancelled]
|
|
148
236
|
end
|
|
149
237
|
end
|
|
150
238
|
end
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -95,7 +95,9 @@ module Samagotchi
|
|
|
95
95
|
@port = port
|
|
96
96
|
@ring = RingBuffer.new(capacity: ring_capacity)
|
|
97
97
|
@accumulator = TurnAccumulator.new
|
|
98
|
-
|
|
98
|
+
# Saved in the session's folder: a later worker, and the web without
|
|
99
|
+
# one, show the cards where they were.
|
|
100
|
+
@cards = CardStore.new(path: File.join(session_dir, CardStore::FILE))
|
|
99
101
|
@pending_card = PendingCard.new(session_dir)
|
|
100
102
|
@heartbeat_interval = heartbeat_interval
|
|
101
103
|
@epoch = SecureRandom.hex(4)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check-in
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.2.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:0a5ba60962206b3ec90bbaa0934a7284416aef495df8a70391d9ea47f7eb9ec1
|
|
10
10
|
requires_chi: ">= 0.3.0"
|
|
@@ -15,12 +15,19 @@
|
|
|
15
15
|
# (a card's actions run while the turn goes on), so the state is behind a
|
|
16
16
|
# Mutex.
|
|
17
17
|
#
|
|
18
|
+
# What /checkin changes (on/off, mode, the threshold) is the session's: it
|
|
19
|
+
# is saved in the bundle's data dir (sessions/<id>.json) and read back
|
|
20
|
+
# when the session comes back (a worker's restart), before its first turn
|
|
21
|
+
# or command. Another session starts from the settings.
|
|
22
|
+
#
|
|
18
23
|
# Settings (config.yml, bundles: check-in:):
|
|
19
24
|
# after: 50 tool calls in one turn before the first check-in
|
|
20
25
|
# every: 50 then again every this many more
|
|
21
26
|
# mode: ask ask | nudge | notify
|
|
22
27
|
# message: "..." what a nudge says; {calls} is the count
|
|
23
28
|
# ignore_tools: [task_wait, task_get, delegate_result]
|
|
29
|
+
require "fileutils"
|
|
30
|
+
require "json"
|
|
24
31
|
require "securerandom"
|
|
25
32
|
|
|
26
33
|
class Plugin
|
|
@@ -39,6 +46,8 @@ class Plugin
|
|
|
39
46
|
@message = message.empty? ? DEFAULT_MESSAGE : message
|
|
40
47
|
@ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
|
|
41
48
|
@enabled = true
|
|
49
|
+
@defaults = { "enabled" => @enabled, "mode" => @mode, "after" => @after, "every" => @every }
|
|
50
|
+
@session = :none # the session whose /checkin state is loaded
|
|
42
51
|
@mutex = Mutex.new
|
|
43
52
|
reset
|
|
44
53
|
end
|
|
@@ -69,7 +78,10 @@ class Plugin
|
|
|
69
78
|
|
|
70
79
|
def before_turn(ctx)
|
|
71
80
|
close_card(ctx, "The turn ended after #{calls} tool calls.")
|
|
72
|
-
@mutex.synchronize
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
load_session(ctx)
|
|
83
|
+
reset
|
|
84
|
+
end
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
def after_tool_call(event, ctx)
|
|
@@ -77,6 +89,7 @@ class Plugin
|
|
|
77
89
|
return if @ignore.include?(tool)
|
|
78
90
|
|
|
79
91
|
due = @mutex.synchronize do
|
|
92
|
+
load_session(ctx)
|
|
80
93
|
@count += 1
|
|
81
94
|
@tools = (@tools + [tool]).last(LAST_TOOLS)
|
|
82
95
|
next nil unless @enabled && @count >= @next_at
|
|
@@ -159,18 +172,25 @@ class Plugin
|
|
|
159
172
|
# --- /checkin --------------------------------------------------------------
|
|
160
173
|
|
|
161
174
|
def command(args, ctx)
|
|
175
|
+
@mutex.synchronize { load_session(ctx) }
|
|
162
176
|
case args
|
|
163
177
|
when "" then status
|
|
164
178
|
when "on", "off"
|
|
165
|
-
@mutex.synchronize
|
|
179
|
+
@mutex.synchronize do
|
|
180
|
+
@enabled = args == "on"
|
|
181
|
+
save_session(ctx)
|
|
182
|
+
end
|
|
166
183
|
"check-in is #{args} for this session"
|
|
167
184
|
when /\A\d+\z/
|
|
168
|
-
threshold(Integer(args))
|
|
185
|
+
threshold(Integer(args), ctx)
|
|
169
186
|
when /\Amode\s+(\S+)\z/
|
|
170
187
|
mode = Regexp.last_match(1)
|
|
171
188
|
return "check-in: unknown mode #{mode} (ask, nudge or notify)" unless MODES.include?(mode)
|
|
172
189
|
|
|
173
|
-
@mutex.synchronize
|
|
190
|
+
@mutex.synchronize do
|
|
191
|
+
@mode = mode
|
|
192
|
+
save_session(ctx)
|
|
193
|
+
end
|
|
174
194
|
"check-in mode is #{mode} for this session"
|
|
175
195
|
when "nudge" then nudge(ctx)
|
|
176
196
|
when "later" then later(ctx)
|
|
@@ -189,12 +209,13 @@ class Plugin
|
|
|
189
209
|
|
|
190
210
|
# For this session: check in at +n+ calls and every +n+ after; a turn
|
|
191
211
|
# already past it is checked on +n+ calls from now.
|
|
192
|
-
def threshold(n)
|
|
212
|
+
def threshold(n, ctx)
|
|
193
213
|
return "check-in: the threshold must be at least 1" unless n.positive?
|
|
194
214
|
|
|
195
215
|
@mutex.synchronize do
|
|
196
216
|
@after = @every = n
|
|
197
217
|
@next_at = n > @count ? n : @count + n
|
|
218
|
+
save_session(ctx)
|
|
198
219
|
end
|
|
199
220
|
"check-in after #{n} tool calls, then every #{n}, for this session"
|
|
200
221
|
end
|
|
@@ -223,6 +244,48 @@ class Plugin
|
|
|
223
244
|
nil
|
|
224
245
|
end
|
|
225
246
|
|
|
247
|
+
# --- the session's /checkin state (under @mutex) ---------------------------
|
|
248
|
+
|
|
249
|
+
# The current session's state, when the session isn't the one loaded:
|
|
250
|
+
# its saved file over the settings.
|
|
251
|
+
def load_session(ctx)
|
|
252
|
+
id = ctx.session_id
|
|
253
|
+
return if id == @session
|
|
254
|
+
|
|
255
|
+
@session = id
|
|
256
|
+
state = @defaults.merge(read_state(ctx, id))
|
|
257
|
+
@enabled = state["enabled"] == true
|
|
258
|
+
@mode = MODES.include?(state["mode"]) ? state["mode"] : @defaults["mode"]
|
|
259
|
+
@after = positive(state["after"]) || @defaults["after"]
|
|
260
|
+
@every = positive(state["every"]) || @defaults["every"]
|
|
261
|
+
@next_at = @after if @count.zero?
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def read_state(ctx, id)
|
|
265
|
+
return {} unless id
|
|
266
|
+
|
|
267
|
+
data = JSON.parse(File.read(state_path(ctx, id)))
|
|
268
|
+
data.is_a?(Hash) ? data : {}
|
|
269
|
+
rescue SystemCallError, JSON::ParserError
|
|
270
|
+
{}
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Written aside and renamed: a reader never sees half a file.
|
|
274
|
+
def save_session(ctx)
|
|
275
|
+
id = ctx.session_id
|
|
276
|
+
return unless id
|
|
277
|
+
|
|
278
|
+
path = state_path(ctx, id)
|
|
279
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
280
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
281
|
+
File.write(temp, JSON.generate({ "enabled" => @enabled, "mode" => @mode, "after" => @after, "every" => @every }))
|
|
282
|
+
File.rename(temp, path)
|
|
283
|
+
rescue SystemCallError => e
|
|
284
|
+
ctx.log.warn("check_in_state_not_saved", msg: e.message) if ctx.respond_to?(:log)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def state_path(ctx, id) = File.join(ctx.data_dir, "sessions", "#{id.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")}.json")
|
|
288
|
+
|
|
226
289
|
# --- helpers ---------------------------------------------------------------
|
|
227
290
|
|
|
228
291
|
def calls = @mutex.synchronize { @count }
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.0
|
|
4
4
|
scope: system
|
|
5
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"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:009dbb3b754958a6d9edb1e3b828f486aa0ddd09ff5b548f0c576abbfea18807
|
|
11
11
|
requires_chi: ">= 0.8.0"
|