samagotchi 0.5.1 → 0.7.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 +77 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +24 -1
- data/docs/desktop.md +52 -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 +7 -2
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +23 -8
- data/lib/samagotchi/desktop_command.rb +1 -1
- 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/update_command.rb +1 -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 +11 -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
|
|
|
@@ -684,6 +687,23 @@ hosts:
|
|
|
684
687
|
`models:` wins over `hosts:`. On a native host, `vision: true` skips only the
|
|
685
688
|
modalities check: without a media marker the prompt can't carry an image.
|
|
686
689
|
|
|
690
|
+
## Desktop helper: agents in kitty
|
|
691
|
+
|
|
692
|
+
The desktop helper (`chi desktop`) can also paste into agent CLIs (claude,
|
|
693
|
+
codex, …) running in [kitty](https://sw.kovidgoyal.net/kitty/) windows. It
|
|
694
|
+
lists them only with `kitty.listen_on` set: copy the value of `listen_on` from
|
|
695
|
+
your `kitty.conf` as it is (kitty must also have `allow_remote_control` on).
|
|
696
|
+
|
|
697
|
+
```yaml
|
|
698
|
+
kitty:
|
|
699
|
+
listen_on: unix:/tmp/kitty.${KITTY_PID} # as in kitty.conf; unset = no kitty targets
|
|
700
|
+
binary: /Applications/kitty.app/Contents/MacOS/kitty # the default
|
|
701
|
+
agents: [claude, codex] # the programs listed; "*" = every window
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
The helper reads these from its launch file: after editing them run
|
|
705
|
+
`chi desktop upgrade` (or `chi update`). See [Desktop helper](desktop.md#agents-in-kitty).
|
|
706
|
+
|
|
687
707
|
## Project specific description
|
|
688
708
|
|
|
689
709
|
If an AGENT.md file is present in the project root, samagotchi injects its
|
|
@@ -753,6 +773,9 @@ described in their own sections.
|
|
|
753
773
|
| `update.gem` | `true` | | `false`: `chi update` never installs a newer gem (`--no-gem` for one run). See [CLI: Updating](cli.md#updating). |
|
|
754
774
|
| `update.bundles` | `true` | | `false`: `chi update` leaves the shipped bundles to `chi bundle upgrade` (`--no-bundles`). |
|
|
755
775
|
| `update.desktop` | `true` | | `false`: `chi update` leaves the desktop helper alone (`--no-desktop`). |
|
|
776
|
+
| `kitty.listen_on` | none | | The desktop helper's kitty socket, copied from `kitty.conf`; unset = no kitty targets. See "Desktop helper: agents in kitty". |
|
|
777
|
+
| `kitty.binary` | `/Applications/kitty.app/Contents/MacOS/kitty` | | The kitty the helper runs `kitty @` with. |
|
|
778
|
+
| `kitty.agents` | `claude\|codex\|gemini\|aider\|opencode\|cursor-agent\|amp\|goose` | | Foreground programs whose kitty windows the helper lists, `\|`-separated (a YAML list works too); `*` lists every window. |
|
|
756
779
|
| `read.truncate_at_bytes` | `65536` | yes | A `read` result larger than this is cut to a preview. |
|
|
757
780
|
| `read.preview_bytes` | `12288` | yes | Size of that preview. |
|
|
758
781
|
| `read.hard_max_bytes` | `2097152` | yes | Largest file `read` opens. |
|
|
@@ -763,7 +786,7 @@ described in their own sections.
|
|
|
763
786
|
| `web.port` | `4567` | `--port` | `chi web`'s port. See [CLI](cli.md). |
|
|
764
787
|
| `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
788
|
| `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
|
|
766
|
-
| `web.view` | `
|
|
789
|
+
| `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
790
|
| `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
791
|
| `history.file` | state dir | | Prompt history path. |
|
|
769
792
|
| `no_interrupt` | `false` | `--no-interrupt` | Raise the tool-call limit of a turn to 1000; a top-level key. |
|
data/docs/desktop.md
CHANGED
|
@@ -20,6 +20,8 @@ sessions. Keys:
|
|
|
20
20
|
- **⏎** sends a message: `chi send -m <the line>` with the text as quoted context above it. With the line empty,
|
|
21
21
|
the text is the message.
|
|
22
22
|
- **⌘⏎** sends a note: `chi note`, the line (if any) then the text.
|
|
23
|
+
- **⌥⏎** pastes into an agent in a kitty window without pressing Enter (see [Agents in kitty](#agents-in-kitty));
|
|
24
|
+
it is not a newline.
|
|
23
25
|
- **⇧⏎** is a newline, in either field. Esc closes.
|
|
24
26
|
|
|
25
27
|
The list shows the sessions a worker runs now, then, under a "recent" divider, up to 3 stopped ones (dimmed, with
|
|
@@ -32,7 +34,9 @@ The first row, **New session in <folder>** (⌘0), starts a session with the mes
|
|
|
32
34
|
<folder>`, so it shows in `chi web` at once (see [Starting a session](sessions.md#starting-a-session)). The folder is
|
|
33
35
|
that of the most recently updated live session, else of the newest recent one, else your home folder. It is ticked
|
|
34
36
|
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.
|
|
37
|
+
note needs a session. After the send the panel shows `started <id>…` for 3 s. On its right, in grey, the row names
|
|
38
|
+
the model the new session starts on (`chi self --model`: `default.model` from config.yml, or its env override); it
|
|
39
|
+
is left out when none is configured.
|
|
36
40
|
|
|
37
41
|
A session open in a `chi --no-shared` REPL isn't listed: it takes no notes or messages.
|
|
38
42
|
|
|
@@ -62,6 +66,53 @@ thumbnails sit under the message line, 48 px high, the file name as a tooltip; h
|
|
|
62
66
|
the panel closes; files from Finder are sent as they are, never touched. A send with images may take up to 30 s
|
|
63
67
|
(converting, a worker starting) before the panel gives up.
|
|
64
68
|
|
|
69
|
+
## Agents in kitty
|
|
70
|
+
|
|
71
|
+
The panel can also send to agent CLIs (claude, codex, gemini, aider, …) running in
|
|
72
|
+
[kitty](https://sw.kovidgoyal.net/kitty/) windows, next to chi sessions. kitty only, through its remote control;
|
|
73
|
+
off until you set it up:
|
|
74
|
+
|
|
75
|
+
1. In `kitty.conf`, remote control on and a socket: `allow_remote_control yes` and a `listen_on unix:…` line.
|
|
76
|
+
2. Copy that `listen_on` value as it is into chi's config.yml:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
kitty:
|
|
80
|
+
listen_on: unix:/tmp/kitty.${KITTY_PID}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`kitty.agents` picks the programs listed (default `claude|codex|gemini|aider|opencode|cursor-agent|amp|goose`, a
|
|
84
|
+
YAML list works too; `"*"` lists every window, shells too), `kitty.binary` the kitty to run (default
|
|
85
|
+
`/Applications/kitty.app/Contents/MacOS/kitty`). See [Configuration](configuration.md#desktop-helper-agents-in-kitty).
|
|
86
|
+
3. Run `chi desktop upgrade` (or `chi update`): the helper reads these from its launch file, so **after every edit of
|
|
87
|
+
`kitty:`** run it again. No rebuild when only the settings changed.
|
|
88
|
+
|
|
89
|
+
The helper finds the sockets itself: kitty adds `-<pid>` to a `listen_on` path from kitty.conf (and fills in
|
|
90
|
+
`{kitty_pid}`), `~` and variables the helper knows are expanded, others (`${KITTY_PID}`) stay as written, as kitty
|
|
91
|
+
keeps them. Several kitty instances all count. Only `unix:` sockets work.
|
|
92
|
+
|
|
93
|
+
The panel lists the windows whose foreground program is one of the agents under a **kitty** divider, between live
|
|
94
|
+
and recent sessions: "claude · <its title>", the folder below, a terminal icon, and a ⌘ number like the sessions.
|
|
95
|
+
With the settings on but no agent running, one dim line says "no agents in kitty". A window counts when a foreground
|
|
96
|
+
process's program is an agent, also as the script of `node …/codex` (that match is untested for codex and gemini).
|
|
97
|
+
|
|
98
|
+
- **⏎** pastes into every chosen window and presses Enter: the text as a `> ` quote, a blank line, your message, then
|
|
99
|
+
one image path per line. Chosen together with chi sessions, each gets the message and the panel shows one line
|
|
100
|
+
("Sent. · sent to claude · samagotchi").
|
|
101
|
+
- **⌥⏎** only pastes (no Enter), and only when every chosen target is a kitty window: build up several screenshots,
|
|
102
|
+
each with its own line, then press Enter in kitty. The footer shows **Paste ⌥⏎** instead of Note then.
|
|
103
|
+
- **⌘⏎** with a kitty window chosen beeps: "Notes are for chi sessions". ⌥⏎ with a chi session: "Paste only is
|
|
104
|
+
for kitty windows".
|
|
105
|
+
- **Images:** a screenshot or other image data is copied to `$TMPDIR/chi-helper-sent/<uuid>.png` (or `.tiff`) and
|
|
106
|
+
its path pasted; the agent reads it from there (claude attaches it as `[Image #1]`). These files are deleted a week
|
|
107
|
+
later, at the helper's next start. Finder files are pasted with their own path, `'…'`-quoted when they hold spaces.
|
|
108
|
+
- The paste arrives as one bracketed paste, so the agent takes newlines as text (claude shows long ones as
|
|
109
|
+
`[Pasted text]`). With `agents: "*"` beware of a program that doesn't turn bracketed paste on: the newlines would
|
|
110
|
+
act as Enter.
|
|
111
|
+
- No delivery confirmation: before pasting, the helper checks the window is still there ("window closed" if not);
|
|
112
|
+
it can't know whether the agent read it. The 16 KB cap is chi's; a kitty window takes more.
|
|
113
|
+
- Focus goes back to the app you came from, as for sessions. The last choice (kitty windows too) is preselected
|
|
114
|
+
while listed; ⌘ numbers shift as windows come and go.
|
|
115
|
+
|
|
65
116
|
## Install
|
|
66
117
|
|
|
67
118
|
```sh
|
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
|