samagotchi 0.5.1 → 0.6.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 +64 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +15 -0
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
data/docs/architecture.md
CHANGED
|
@@ -110,7 +110,7 @@ bin/chi ─▶ TerminalUI ─▶ Engine#run_turn ─▶ KernelLoop ──┬─
|
|
|
110
110
|
`dismiss_question`). The worker runs in the session's `working_directory`, so `!cmd` and
|
|
111
111
|
the tools don't depend on where the terminal attached from.
|
|
112
112
|
- `bin/chi web` → builds `Web::Server` (Rack+WEBrick on `127.0.0.1:4567`, `--port`/`SAMAGOTCHI_WEB_PORT`, `--open`).
|
|
113
|
-
- `bin/chi sessions {list,stop,delete,prune,clean}` → retention & ordering (`Session.prune`, `updated_at desc`, dry-run, test-only); `stop` is `SessionManager.stop_session(wait:)`, which waits for the worker to release `owner.lock`; `delete` (`SessionDeleteCommand`) is `SessionManager.delete_session(stop:)`, which the TUI's `/exit --delete` and the web's `DELETE /api/sessions/:id` use too.
|
|
113
|
+
- `bin/chi sessions {list,stop,delete,prune,clean}` (`SessionsCommand`; not the REPL's `SessionCommands`) → retention & ordering (`Session.prune`, `updated_at desc`, dry-run, test-only); `stop` is `SessionManager.stop_session(wait:)`, which waits for the worker to release `owner.lock`; `delete` (`SessionDeleteCommand`) is `SessionManager.delete_session(stop:)`, which the TUI's `/exit --delete` and the web's `DELETE /api/sessions/:id` use too.
|
|
114
114
|
- `--prompt`, `--non-interactive`, and `SessionManager` workers build `Engine` directly.
|
|
115
115
|
|
|
116
116
|
## Session retention & ordering
|
|
@@ -275,6 +275,13 @@ its model turns keep their `tool_calls` and tool results their `tool_call_id`,
|
|
|
275
275
|
so later requests and resumed sessions pair them. It has its own system prompt
|
|
276
276
|
(no raw-prompt tool declarations; the tools go as JSON schemas).
|
|
277
277
|
|
|
278
|
+
`Engine#build_stream_event_handler` is the one seam both loops' streams pass
|
|
279
|
+
through: with a `:generation_progress` hook registered it feeds a
|
|
280
|
+
`Hooks::StreamWatch` each chunk's thinking and text after the UIs had it. A
|
|
281
|
+
hook's `stop_generation` cancels the generation's own controller (a child of
|
|
282
|
+
the turn's, `CancellationController#generation`), and each loop takes the cut
|
|
283
|
+
as an empty answer and asks again.
|
|
284
|
+
|
|
278
285
|
**Adapters.** `Client` (raw-prompt servers) and `LLM::OpenAIChat` (one per host,
|
|
279
286
|
`HostRegistry#adapter_for`) share `LLM::HTTP`: timeouts, TLS for https, a line
|
|
280
287
|
reader for streamed bodies, the retry loop (`retry.*`; network errors, 429 and
|
data/docs/cli.md
CHANGED
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
- `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
|
|
15
15
|
- `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
|
|
16
16
|
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
|
|
17
|
-
- `chi web --web-view
|
|
17
|
+
- `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
|
|
18
18
|
- `chi sessions list|stop|archive|unarchive|delete|prune|clean` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md))
|
|
19
19
|
- `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
|
|
20
20
|
- `chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one picks it up); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead, and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; see [Starting a session](sessions.md#starting-a-session))
|
|
21
21
|
- `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text to live sessions as context notes (see [Desktop helper](desktop.md))
|
|
22
|
-
- `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles
|
|
22
|
+
- `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles; `chi self --model` prints only the model a new session starts on (the desktop helper's hint)
|
|
23
23
|
- `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
|
|
24
24
|
- `chi bundle install|upgrade|uninstall|status|diff|list|build` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs (see [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle))
|
|
25
25
|
|
|
@@ -420,26 +420,25 @@ its code; a prompt copies the text as you typed it.
|
|
|
420
420
|
|
|
421
421
|
### Web views
|
|
422
422
|
|
|
423
|
-
`web.view` picks how the page draws a turn: `
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
answer bubble per generation).
|
|
423
|
+
`web.view` picks how the page draws a turn: `stage` (the default: the
|
|
424
|
+
running turn pinned above the composer, below) or `turn` (each turn as one
|
|
425
|
+
block of steps in the history, the running one at the bottom, below).
|
|
427
426
|
|
|
428
427
|
```sh
|
|
429
|
-
chi web --web-view
|
|
428
|
+
chi web --web-view turn # stage is the default
|
|
430
429
|
```
|
|
431
430
|
|
|
432
|
-
The setting also supports `SAMAGOTCHI_WEB_VIEW=
|
|
431
|
+
The setting also supports `SAMAGOTCHI_WEB_VIEW=turn` or the global config:
|
|
433
432
|
|
|
434
433
|
```yaml
|
|
435
434
|
web:
|
|
436
|
-
view:
|
|
435
|
+
view: turn
|
|
437
436
|
```
|
|
438
437
|
|
|
439
|
-
`?view=
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
438
|
+
`?view=stage` or `?view=turn` on the page URL picks the view for that page
|
|
439
|
+
load, whatever the config says; the parameter is dropped when you switch
|
|
440
|
+
between the project and all-sessions views. The terminal UIs are not
|
|
441
|
+
affected.
|
|
443
442
|
|
|
444
443
|
**The stage view** pins the running turn above the composer, in its own
|
|
445
444
|
card, so it stays on screen without scrolling: a status row (what it is
|
data/docs/configuration.md
CHANGED
|
@@ -538,6 +538,9 @@ unless `sampling:` sets one; the REPL prints `↻ empty answer, asking again (1/
|
|
|
538
538
|
the step. `retry.empty_answer` (default `1`, at most `3`, env `SAMAGOTCHI_RETRY_EMPTY_ANSWER`, no CLI flag) is how
|
|
539
539
|
many times per turn; `0` ends the turn at the empty answer as before. An answer cut because the context is full
|
|
540
540
|
(90 % or more) is not retried. When the retries run out the turn ends with "(the model returned an empty answer)".
|
|
541
|
+
A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
|
|
542
|
+
[hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
|
|
543
|
+
none left the turn ends as cancelled (hook).
|
|
541
544
|
|
|
542
545
|
Assist-mode UX:
|
|
543
546
|
|
|
@@ -763,7 +766,7 @@ described in their own sections.
|
|
|
763
766
|
| `web.port` | `4567` | `--port` | `chi web`'s port. See [CLI](cli.md). |
|
|
764
767
|
| `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token (`chi web --new-token` replaces it). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
|
|
765
768
|
| `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
|
|
766
|
-
| `web.view` | `
|
|
769
|
+
| `web.view` | `stage` | yes | How `chi web` draws a turn: `stage` (the running turn pinned above the composer) or `turn` (one block per turn in the history). See [CLI](cli.md#web-views). |
|
|
767
770
|
| `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
|
|
768
771
|
| `history.file` | state dir | | Prompt history path. |
|
|
769
772
|
| `no_interrupt` | `false` | `--no-interrupt` | Raise the tool-call limit of a turn to 1000; a top-level key. |
|
data/docs/desktop.md
CHANGED
|
@@ -32,7 +32,9 @@ The first row, **New session in <folder>** (⌘0), starts a session with the mes
|
|
|
32
32
|
<folder>`, so it shows in `chi web` at once (see [Starting a session](sessions.md#starting-a-session)). The folder is
|
|
33
33
|
that of the most recently updated live session, else of the newest recent one, else your home folder. It is ticked
|
|
34
34
|
alone (ticking it clears the sessions and the reverse) and is preselected when no session is live. ⌘⏎ on it beeps: a
|
|
35
|
-
note needs a session. After the send the panel shows `started <id>…` for 3 s.
|
|
35
|
+
note needs a session. After the send the panel shows `started <id>…` for 3 s. On its right, in grey, the row names
|
|
36
|
+
the model the new session starts on (`chi self --model`: `default.model` from config.yml, or its env override); it
|
|
37
|
+
is left out when none is configured.
|
|
36
38
|
|
|
37
39
|
A session open in a `chi --no-shared` REPL isn't listed: it takes no notes or messages.
|
|
38
40
|
|
data/docs/hooks.md
CHANGED
|
@@ -52,14 +52,15 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
52
52
|
| `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), messages: [...] (the history before this turn) }` |
|
|
53
53
|
| `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
|
|
54
54
|
| `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
|
|
55
|
-
| `:after_generation` | After LLM returns (both loops) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
55
|
+
| `: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) }` |
|
|
56
|
+
| `: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 }` |
|
|
56
57
|
| `: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 }` |
|
|
57
58
|
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." }` |
|
|
58
59
|
| `:session_end` | After every turn (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
|
|
59
60
|
|
|
60
61
|
Every event also carries the hook runtime (next section): `hook:` (the label
|
|
61
62
|
of the hook about to run) and the callables `notify:`, `ask_user:`,
|
|
62
|
-
`stop_turn:`, `steer:`.
|
|
63
|
+
`stop_turn:`, `steer:`, `stop_generation:`.
|
|
63
64
|
|
|
64
65
|
`messages:` is a **read-only copy**: a frozen array of copied message hashes
|
|
65
66
|
(`{role:, content:, …}`). A hook that mutates it, or its strings, gets
|
|
@@ -69,8 +70,9 @@ cheap).
|
|
|
69
70
|
## What a hook can do: the runtime
|
|
70
71
|
|
|
71
72
|
Besides reading (and, on `:before_tool_call`, voting on) its event, a hook
|
|
72
|
-
can talk to the user, and to the running turn, through
|
|
73
|
-
registry puts on every event
|
|
73
|
+
can talk to the user, and to the running turn, through the callables the
|
|
74
|
+
registry puts on every event (`stop_generation`, the fifth, is in
|
|
75
|
+
[Watching the stream](#watching-the-stream)):
|
|
74
76
|
|
|
75
77
|
```ruby
|
|
76
78
|
class Watchful
|
|
@@ -114,12 +116,75 @@ known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
|
|
|
114
116
|
A steer is saved in the session as `{role: "user", kind: "steer", source:
|
|
115
117
|
"<bundle>", content: "…"}`; the model reads only its text, as a user turn.
|
|
116
118
|
Its `source` is the hook's bundle (a config or turn hook's label otherwise).
|
|
119
|
+
The user's own lines typed into a running turn are saved as `{role: "user",
|
|
120
|
+
kind: "input", content: "…"}`: part of that turn, not a turn of their own.
|
|
117
121
|
|
|
118
122
|
Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
|
|
119
123
|
end line. A question from `:before_tool_call` shows **before** the tool
|
|
120
124
|
line (the gate runs first), so its text should name the call. The notices
|
|
121
125
|
are also logged (`turn` tag, `hook_notice`).
|
|
122
126
|
|
|
127
|
+
## Watching the stream
|
|
128
|
+
|
|
129
|
+
`:generation_progress` sees a model response **while it streams**: the
|
|
130
|
+
thinking and the visible text, on both the raw-prompt path (llama.cpp
|
|
131
|
+
`/completion`, `/v1/completions`) and the chat path (`api: openai`). It fires
|
|
132
|
+
for the turn's own generations only (not a plugin's `ctx.ask_model`, not a
|
|
133
|
+
recap).
|
|
134
|
+
|
|
135
|
+
When it fires: once 2000 new chars (thinking + text) are pending, or once a
|
|
136
|
+
second has passed since the last fire with anything pending. It is checked
|
|
137
|
+
as each chunk arrives (there is no timer), so a silent stream fires nothing.
|
|
138
|
+
There is no fire at the end of a generation (`:after_generation` sees the
|
|
139
|
+
whole response), and none after the turn or the generation was cancelled.
|
|
140
|
+
A 240k-char thinking gives about 120 fires.
|
|
141
|
+
|
|
142
|
+
`thinking` holds what the model streamed as thinking: `reasoning_content`
|
|
143
|
+
on the chat path, a Qwen `<think>` block on the raw-prompt path. Some
|
|
144
|
+
thinking arrives as `text` instead: Gemma 4's on the raw-prompt path (it has
|
|
145
|
+
no close marker), and a chat provider's that puts `<think>` inside the
|
|
146
|
+
answer's content. Tool-call bodies are left out of `text`. With streaming off
|
|
147
|
+
(`stream: false` on a chat host) there are no chunks, so no fires.
|
|
148
|
+
|
|
149
|
+
**Keep it fast.** The hook runs on the turn's thread, inside the HTTP read:
|
|
150
|
+
while it runs, tokens wait in the socket (nothing is lost). A hook over
|
|
151
|
+
100 ms logs `stream_hook_slow` (warn, with its label and the ms) once per
|
|
152
|
+
turn. Don't ask questions or call the model from it: `event[:ask_user]`
|
|
153
|
+
returns nil at once here, and `ctx.ask_model` would hold the stream for its
|
|
154
|
+
whole answer. A hook that never returns hangs the turn, as any hook does. A
|
|
155
|
+
plugin's block that raises is logged (`plugin_hook_failed`) at most once a
|
|
156
|
+
minute.
|
|
157
|
+
|
|
158
|
+
Two ways to act:
|
|
159
|
+
|
|
160
|
+
- `event[:stop_turn].call(reason)`: as anywhere, a warn notice and the turn
|
|
161
|
+
ends cancelled (hook). The socket closes at once.
|
|
162
|
+
- `event[:stop_generation].call(reason)`: cut **this generation** only. The
|
|
163
|
+
turn goes on: the model is asked again with a hidden note ("your last
|
|
164
|
+
reply was cut off by <bundle>: <reason>. Don't start the same reasoning
|
|
165
|
+
again; …") at the retry temperature, and the UIs print `↻ cut by
|
|
166
|
+
<bundle>, asking again (1/1)`. True when a streaming generation was cut
|
|
167
|
+
now; false with none streaming, once it was cut, after a cancel, and from
|
|
168
|
+
`:after_turn` / `:session_end`.
|
|
169
|
+
|
|
170
|
+
What a cut is, in detail:
|
|
171
|
+
|
|
172
|
+
- It is an empty answer made early: it uses the `retry.empty_answer` budget
|
|
173
|
+
([configuration.md](configuration.md#llama-network-retry-behavior)). With
|
|
174
|
+
no retry left (`retry.empty_answer: 0`, or already used), the turn ends
|
|
175
|
+
cancelled (hook): "✕ turn canceled (hook)".
|
|
176
|
+
- It shows nothing by itself: post your own notice (`event[:notify]`) to say
|
|
177
|
+
why. The bundle and the reason go into the note the model reads and the
|
|
178
|
+
log (`generation_stopped`).
|
|
179
|
+
- Queued input (the user's line, or a hook's `steer`) goes in place of the
|
|
180
|
+
hidden note, as for an empty answer; then there is no `↻` line.
|
|
181
|
+
- The cut generation is not kept: its thinking and any visible text it had
|
|
182
|
+
streamed go with it (the retry answers anew). `:after_generation` doesn't
|
|
183
|
+
fire for it, and its token usage is lost (the stream never sent its last
|
|
184
|
+
chunk). A client that joins the running turn still sees its thinking in
|
|
185
|
+
the turn so far until the turn ends.
|
|
186
|
+
- A Stop from the user right after a cut is a plain cancel.
|
|
187
|
+
|
|
123
188
|
## Presenting the answer (display only)
|
|
124
189
|
|
|
125
190
|
`:after_turn` carries one more callable, `present:`. It changes how the
|
data/docs/plugins.md
CHANGED
|
@@ -311,6 +311,34 @@ This is a bundle hook, the same as a `hooks/*.rb` file. See
|
|
|
311
311
|
`plugin.rb (bundle my-bundle)`. The block may take only the event. If it
|
|
312
312
|
raises, the error is logged and the hook is skipped.
|
|
313
313
|
|
|
314
|
+
#### Watching the stream
|
|
315
|
+
|
|
316
|
+
`chi.on(:generation_progress)` sees a response while it streams, in batches
|
|
317
|
+
(2000 chars or a second), and `event[:stop_generation]` (or
|
|
318
|
+
`ctx.stop_generation`) cuts it while the turn goes on: the model is asked
|
|
319
|
+
again. The rules (the thread it runs on, keeping it fast, what a cut does)
|
|
320
|
+
are in [hooks.md](hooks.md#watching-the-stream).
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
class Plugin
|
|
324
|
+
LIMIT = 50_000
|
|
325
|
+
|
|
326
|
+
def register(chi)
|
|
327
|
+
chi.on(:generation_progress) do |event, ctx|
|
|
328
|
+
next if event[:thinking_chars] < LIMIT
|
|
329
|
+
|
|
330
|
+
# Once per generation: a cut generation fires no more.
|
|
331
|
+
if event[:stop_generation].call("it thought for over #{LIMIT / 1000}k chars")
|
|
332
|
+
ctx.notify("thinking too long (#{event[:elapsed_ms] / 1000} s): cut", level: :warn)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
loop-guard's thinking watch is built on this ([The loop-guard
|
|
340
|
+
bundle](#the-loop-guard-bundle)).
|
|
341
|
+
|
|
314
342
|
### `chi.service(name, eager: false) { |svc| … }`
|
|
315
343
|
|
|
316
344
|
A long-lived thing the plugin keeps for the session: a server process, a
|
|
@@ -439,6 +467,7 @@ session's life, and each read gives the session as it is now.
|
|
|
439
467
|
| `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
|
|
440
468
|
| `ctx.steer(text)` | put text into the running turn, like a hook's `event[:steer]`: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook, your own |
|
|
441
469
|
| `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason, like a hook's `event[:stop_turn]`; true when it stopped one now |
|
|
470
|
+
| `ctx.stop_generation(reason)` | cut the generation that is streaming, like a hook's `event[:stop_generation]`: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
|
|
442
471
|
| `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
|
|
443
472
|
| `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
|
|
444
473
|
|
|
@@ -706,7 +735,9 @@ bundles:
|
|
|
706
735
|
|
|
707
736
|
`chi bundle install loop-guard` installs the bundle shipped with chi. It is
|
|
708
737
|
written only against this API (`lib/samagotchi/bundles/loop-guard/plugin.rb`),
|
|
709
|
-
with `chi.on` hooks, and has no memory file.
|
|
738
|
+
with `chi.on` hooks, and has no memory file. It guards two kinds of loop:
|
|
739
|
+
repeated tool calls (below) and [thinking that repeats
|
|
740
|
+
itself](#thinking-that-repeats-itself).
|
|
710
741
|
|
|
711
742
|
A local model can run the same tool call again and again in one turn: each
|
|
712
743
|
step's thinking starts over, so it never notices it already tried. (A real
|
|
@@ -749,12 +780,76 @@ bundles:
|
|
|
749
780
|
Its hooks run at the default priority (100), after known-names (50), so in
|
|
750
781
|
known-names' `correct` mode loop-guard keys the corrected call.
|
|
751
782
|
|
|
783
|
+
### Thinking that repeats itself
|
|
784
|
+
|
|
785
|
+
A model can also go in circles inside one generation: its thinking says the
|
|
786
|
+
same few sentences again and again ("Wait, let me check once more…") for
|
|
787
|
+
minutes, until the provider's output cap ends it with no answer. loop-guard
|
|
788
|
+
watches the thinking while it streams ([Watching the
|
|
789
|
+
stream](hooks.md#watching-the-stream)) and cuts it:
|
|
790
|
+
|
|
791
|
+
- The thinking is cut into sentences; short ones (under `min_words`) are
|
|
792
|
+
skipped. Two sentences count as the same when their words and word pairs
|
|
793
|
+
mostly match (`similarity`), so a reworded round ("Hmm, …", a synonym, a
|
|
794
|
+
swapped clause) still counts.
|
|
795
|
+
- A loop is a cycle of 1 to `max_period` sentences seen `repeats` times in a
|
|
796
|
+
row, over at least `min_span_sentences` sentences and `min_span_chars`
|
|
797
|
+
chars; or one sentence `max_same` times anywhere in the generation. One
|
|
798
|
+
sentence over and over must be near-identical (`similarity` + 0.4), and a
|
|
799
|
+
longer cycle must hold different sentences: a list of templated sentences
|
|
800
|
+
("Now I need to open the file <path> and …" for 14 paths) is no loop.
|
|
801
|
+
- Nothing triggers before `min_chars` of thinking. The watch sees a loop
|
|
802
|
+
within one batch (2000 chars, or a second) of its third cycle.
|
|
803
|
+
|
|
804
|
+
What happens (`action: retry`, the default):
|
|
805
|
+
|
|
806
|
+
1. The first loop in a turn: the generation is cut and the model asked
|
|
807
|
+
again, with a hidden note that it was cut off. The user sees
|
|
808
|
+
`loop-guard> thinking repeats itself (3 sentences ×3, 4k chars, 8 s): cut`
|
|
809
|
+
and `↻ cut by loop-guard, asking again (1/1)`.
|
|
810
|
+
2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
|
|
811
|
+
turn canceled (hook)") with a card that quotes the repeated sentences.
|
|
812
|
+
|
|
813
|
+
The cut uses the `retry.empty_answer` budget: with `retry.empty_answer: 0`
|
|
814
|
+
the first loop ends the turn as cancelled (hook), with the notice and no
|
|
815
|
+
card. `action: stop` stops the turn at the first loop; `action: notify` only
|
|
816
|
+
warns, once per generation.
|
|
817
|
+
|
|
818
|
+
```yaml
|
|
819
|
+
bundles:
|
|
820
|
+
loop-guard:
|
|
821
|
+
thinking:
|
|
822
|
+
watch: true # false: the tool-call guard only
|
|
823
|
+
action: retry # retry (cut, ask again; then stop) | stop | notify
|
|
824
|
+
min_chars: 2000 # thinking this long before anything triggers
|
|
825
|
+
repeats: 3 # a cycle seen this many times is a loop
|
|
826
|
+
max_period: 6 # cycles of up to this many sentences
|
|
827
|
+
similarity: 0.5 # 0..1, how alike two sentences must be to count as the same
|
|
828
|
+
min_span_sentences: 6 # a loop spans at least this many sentences…
|
|
829
|
+
min_span_chars: 600 # …and this many chars
|
|
830
|
+
max_same: 8 # one sentence this many times in one generation is a loop
|
|
831
|
+
min_words: 5 # shorter sentences are ignored
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
Not watched:
|
|
835
|
+
|
|
836
|
+
- thinking that arrives as answer text: Gemma 4 on the raw-prompt path (no
|
|
837
|
+
close marker), a chat provider that puts `<think>` in the content;
|
|
838
|
+
- a chat host with streaming off (no chunks);
|
|
839
|
+
- loops in the visible answer;
|
|
840
|
+
- a runaway without sentences, such as an endless comma list of numbers: it
|
|
841
|
+
ends at the output cap as an empty answer, which `retry.empty_answer`
|
|
842
|
+
retries after the fact.
|
|
843
|
+
|
|
844
|
+
A real small model's circular re-checking that repeats one sentence 8 times
|
|
845
|
+
is cut even if it would have found an answer later; `max_same` sets how
|
|
846
|
+
patient that is.
|
|
847
|
+
|
|
752
848
|
Not caught (yet):
|
|
753
849
|
|
|
754
850
|
- near-duplicates, such as `find . -name 'config*'` after `'config.yml'`;
|
|
755
851
|
- loops across turns;
|
|
756
|
-
- alternating calls (A, B, A, B) that each return something new
|
|
757
|
-
- thinking that goes in circles inside one long generation.
|
|
852
|
+
- alternating calls (A, B, A, B) that each return something new.
|
|
758
853
|
|
|
759
854
|
## The check-in bundle
|
|
760
855
|
|
data/docs/sessions.md
CHANGED
|
@@ -50,7 +50,7 @@ chi sessions clean --dry-run # every test session, whatever
|
|
|
50
50
|
chi sessions clean --dry-run --days 7 # test sessions older than 7 days
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
`--dry-run` is the safe preview. Web has no prune endpoint; use the CLI.
|
|
53
|
+
`--dry-run` is the safe preview. `--keep N` keeps the N newest sessions that stay: one that goes anyway (a leftover scratch session, one left empty, one older than `--days`) takes no place in the count. Web has no prune endpoint; use the CLI.
|
|
54
54
|
|
|
55
55
|
`chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
|
|
56
56
|
|
|
@@ -175,8 +175,8 @@ chi send --wait 3fa2 # sends nothing: waits fo
|
|
|
175
175
|
|
|
176
176
|
The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; nothing checks the host serves it, so an unknown one fails the child's first turn). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
|
|
177
177
|
|
|
178
|
-
- `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). 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.
|
|
179
|
-
- `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child. The reply comes back once; a later call waits for the next one.
|
|
178
|
+
- `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.
|
|
179
|
+
- `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.
|
|
180
180
|
- What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval (nothing is answered for it: attach or use the web, then wait again); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `running` on timeout. A reply saved before the child's worker idle-exited is returned at once.
|
|
181
181
|
- 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.
|
|
182
182
|
- 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.
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
class Bridge
|
|
5
|
-
# The last cards (Engine#show_card)
|
|
6
|
-
# for a UI that joins later: the
|
|
7
|
-
# persistent observer, like TurnAccumulator.
|
|
5
|
+
# The last cards (Engine#show_card), hook notices (:hook_notice) and
|
|
6
|
+
# load warnings (:guardrail_warning), for a UI that joins later: the
|
|
7
|
+
# Bridge's snapshot[:cards]. A persistent observer, like TurnAccumulator.
|
|
8
8
|
#
|
|
9
9
|
# A turn's own notice (no between_turns) is in_turn like a turn's card,
|
|
10
10
|
# with the step it came in: +iteration+ and +calls+, the calls of that
|
|
11
11
|
# iteration started before it (a before_tool_call hook's notice comes
|
|
12
|
-
# before its call's row, so the web puts it above row calls + 1).
|
|
12
|
+
# before its call's row, so the web puts it above row calls + 1). The
|
|
13
|
+
# loop's "asking again" row (:empty_answer_retry, after an empty or cut
|
|
14
|
+
# answer) is kept the same way, and so is a question the turn asked
|
|
15
|
+
# (:question_requested), with how it was answered or cancelled, so a
|
|
16
|
+
# reload draws the resolved card where it was.
|
|
13
17
|
#
|
|
14
18
|
# A card with an earlier card's id replaces it where it was. Each entry
|
|
15
19
|
# says where it belongs as the recap does: +turns_since+, the turns
|
|
@@ -81,6 +85,15 @@ module Samagotchi
|
|
|
81
85
|
settle_during(after_turn: true)
|
|
82
86
|
when :card then add_card(event)
|
|
83
87
|
when :hook_notice then add_notice(event)
|
|
88
|
+
# What failed to load (guardrails, plugins): before the first turn
|
|
89
|
+
# this worker runs, where it showed live.
|
|
90
|
+
when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
|
|
91
|
+
.compact.merge(in_turn: false, turns: @turns_done))
|
|
92
|
+
when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by)) if @running
|
|
93
|
+
when :question_requested
|
|
94
|
+
add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
|
|
95
|
+
when :question_answered then resolve_question(event[:id], answer: event[:answer])
|
|
96
|
+
when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
|
|
84
97
|
end
|
|
85
98
|
end
|
|
86
99
|
|
|
@@ -113,10 +126,22 @@ module Samagotchi
|
|
|
113
126
|
if event[:between_turns] || !@running
|
|
114
127
|
push(notice.merge(in_turn: false, turns: @turns_done))
|
|
115
128
|
else
|
|
116
|
-
|
|
129
|
+
add_turn_row(notice)
|
|
117
130
|
end
|
|
118
131
|
end
|
|
119
132
|
|
|
133
|
+
# A row of the running turn's current step.
|
|
134
|
+
def add_turn_row(row)
|
|
135
|
+
push(row.merge(in_turn: true, turns: @turns_done, iteration: @iteration, calls: @calls).compact)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolve_question(id, **outcome)
|
|
139
|
+
entry = @entries.reverse_each.find do |e|
|
|
140
|
+
e[:type] == :question && e.dig(:pending_question, :id).to_s == id.to_s
|
|
141
|
+
end
|
|
142
|
+
entry&.merge!(Marshal.load(Marshal.dump(outcome.compact)))
|
|
143
|
+
end
|
|
144
|
+
|
|
120
145
|
def push(entry)
|
|
121
146
|
@entries << entry
|
|
122
147
|
@entries.shift while @entries.size > @capacity
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../kernel_loop"
|
|
4
4
|
require_relative "../steer"
|
|
5
|
+
require_relative "turn_notice"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
class Bridge
|
|
@@ -33,8 +34,11 @@ module Samagotchi
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# @return [Hash, nil] a copy of the turn in progress: prompt, origin,
|
|
36
|
-
# continue, ordered parts (thinking / text / tool / input / steer /
|
|
37
|
-
# pending_question and the last event_seq folded
|
|
37
|
+
# continue, ordered parts (thinking / text / tool / input / steer /
|
|
38
|
+
# reminder / notice), pending_question and the last event_seq folded
|
|
39
|
+
# in. A notice part holds the event of one of the turn's rows
|
|
40
|
+
# (TurnNotice: a hook's notice, an empty-answer retry, a question and
|
|
41
|
+
# its answer), which a UI replays through its live handler.
|
|
38
42
|
def current_turn
|
|
39
43
|
@mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
|
|
40
44
|
end
|
|
@@ -173,13 +177,12 @@ module Samagotchi
|
|
|
173
177
|
end
|
|
174
178
|
when :reminder_injected
|
|
175
179
|
parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
|
|
176
|
-
when :hook_notice
|
|
177
|
-
parts << { kind: "hook_notice", hook: event[:hook].to_s, text: event[:text].to_s, level: event[:level].to_s }
|
|
178
180
|
when :question_requested
|
|
179
181
|
@turn[:pending_question] = event[:pending_question]&.dup
|
|
180
182
|
when :question_answered, :question_cancelled
|
|
181
183
|
@turn[:pending_question] = nil
|
|
182
184
|
end
|
|
185
|
+
parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
|
|
183
186
|
end
|
|
184
187
|
|
|
185
188
|
def append_text(iteration, kind, text)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Bridge
|
|
5
|
+
# The events a running turn shows as rows of its own (not text, thinking
|
|
6
|
+
# or a tool call): a hook's notice, the loop asking again after an empty
|
|
7
|
+
# or cut answer, a question and how it was answered. A UI that joins the
|
|
8
|
+
# turn mid-way (TurnAccumulator's "notice" part) and one that reloads a
|
|
9
|
+
# finished turn (CardStore) get the event back with the fields listed
|
|
10
|
+
# here, and draw it with the handler that drew it live, where it was.
|
|
11
|
+
module TurnNotice
|
|
12
|
+
FIELDS = {
|
|
13
|
+
hook_notice: %i[hook text level],
|
|
14
|
+
empty_answer_retry: %i[iteration attempt of stopped_by],
|
|
15
|
+
question_requested: %i[pending_question],
|
|
16
|
+
question_answered: %i[id answer],
|
|
17
|
+
question_cancelled: %i[id reason]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @return [Boolean] whether +event+ is one of a turn's rows
|
|
23
|
+
def notice?(event)
|
|
24
|
+
FIELDS.key?(event[:type])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The event as a UI replays it: its type (a String, as JSON has it)
|
|
28
|
+
# and the fields it had, deep-copied.
|
|
29
|
+
# @return [Hash]
|
|
30
|
+
def slice(event)
|
|
31
|
+
fields = event.slice(*FIELDS.fetch(event[:type]))
|
|
32
|
+
copy = Marshal.load(Marshal.dump(fields))
|
|
33
|
+
{ type: event[:type].to_s, **copy }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|