samagotchi 0.20.0 → 0.21.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +2 -1
  5. data/docs/plugins.md +22 -8
  6. data/docs/sessions.md +2 -2
  7. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  9. data/lib/samagotchi/bridge.rb +16 -4
  10. data/lib/samagotchi/bridge_client.rb +33 -9
  11. data/lib/samagotchi/bundle_command.rb +6 -0
  12. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +15 -2
  16. data/lib/samagotchi/events.rb +1 -1
  17. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  18. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  19. data/lib/samagotchi/log_subscriber.rb +4 -2
  20. data/lib/samagotchi/session_manager.rb +26 -9
  21. data/lib/samagotchi/session_metrics.rb +6 -4
  22. data/lib/samagotchi/steer.rb +1 -1
  23. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  24. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  25. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  26. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  27. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  28. data/lib/samagotchi/version.rb +1 -1
  29. data/lib/samagotchi/web/public/app.js +34 -7
  30. data/lib/samagotchi/web/public/index.html +7 -0
  31. data/lib/samagotchi/web/public/notifications.js +8 -3
  32. data/lib/samagotchi/web/public/notify.js +49 -7
  33. data/lib/samagotchi/web/public/timing.js +5 -3
  34. data/lib/samagotchi/web/public/turn_events.js +16 -0
  35. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3b1a6860a8c22ed75259438c755633283385c12ffbc28a8fcb4c817f59114c5
4
- data.tar.gz: 24f7c45ebe336077bc2d9a1e670d3313b364c48d5b28aef3624593705236f29e
3
+ metadata.gz: f9d794c2749a0f1293cb16d7afeb7e023f38dd8798532e4dcb2d0e9fcb575af1
4
+ data.tar.gz: 8ade3692c344214cc8cdbe90c1fb8539242639f7cbf8707c751a8d2b02f9548a
5
5
  SHA512:
6
- metadata.gz: 9f1d9dec24344b223170728f80629a76d1db18697f752b2ce7135cf3569428a902f1cc18589a928184f7809bcbe3a20289281cd4cae6324bdd3f5bb1192cf860
7
- data.tar.gz: 8bf9a7cf6dbaac98a7583987be61d98e06ddd3ab6a0b6f36423ae41a6aa530ef42479fa28139ad89b8253e61edce1a57013dd27d0a9bcbf5b31c080cea6f8801
6
+ metadata.gz: b3eb054e3b0dd3ded63ef8de4bfff37d8d36971dbe02107ac2e30114fadc12563807d4c7bdc4d3fdf8b801a7bd8250fcb5e73a547604b0feba7382abfdff0ee7
7
+ data.tar.gz: 168c3cfef2a37c4f5e3f6e2782b614a546f8307793e41af0efe200b2afa8d100b725889e5bb8fcf520fa7c2a0be18faa4cedb10a9ddf573bedac162a1404f133
data/CHANGELOG.md CHANGED
@@ -8,6 +8,52 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.21.0] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - The debug log's generation_completed line names the OpenRouter provider that served it (provider=).
16
+
17
+ ### Fixed
18
+
19
+ - loop-guard cuts thinking that loops in short sentences ("I'll write it. Go. OK. Writing."),
20
+ which it skipped before: such a loop ran for minutes to the provider's output cap. New settings
21
+ `thinking.short_run` (24) and `thinking.short_distinct` (6).
22
+ - loop-guard's thinking watch no longer goes blind for the rest of a generation after an empty thinking
23
+ chunk (a FrozenError, logged as `plugin_hook_failed`), so it now sees every generation's thinking.
24
+ - loop-guard no longer stops a whole turn for a thinking loop that comes long after the model recovered from
25
+ its first one: after `thinking.forget_after` (10) steps with no loop, the next loop is cut and retried again.
26
+ - A turn a plugin stopped now says who did it, `✕ turn stopped by loop-guard` (the web: `✕ stopped by
27
+ loop-guard`), instead of `✕ canceled (by a hook)`, also after a reload of the web page.
28
+ - An attached `/exit` the worker reads only after the TUI stopped waiting (a frozen or sleeping worker) no
29
+ longer stops it later: the request is dropped, as late turns and commands are.
30
+ - The attached `/exit` (and `/exit --delete`, `/archive`) in an empty session no longer claims the session is
31
+ already gone: a note arriving before the worker stops keeps it, so it says it will be discarded if nothing
32
+ arrives.
33
+ - Archiving a session (`/archive`, `chi sessions archive`, the web) with a prompt still queued or a step-limit
34
+ question open is refused with a message saying so, instead of stopping the worker and leaving the queued
35
+ prompt to run on the next resume.
36
+ - `/exit` right after a `/detach` and `chi --attach` no longer says "another UI is attached" and leaves the worker
37
+ up: the worker notices a closed stream at once instead of at its next 15-second heartbeat.
38
+ - Leaving chi's terminal UI (`/exit`, `/detach`, Ctrl-D) no longer can leave a stray cursor report such as
39
+ `^[[12;1R` at the shell prompt: input nobody read is dropped before the terminal is handed back.
40
+ - Joining a running turn (web or `chi --attach`) while the model is holding before its first token now shows
41
+ the step it's on, as a live view does, instead of the previous step still live.
42
+ - Web: sending into a session whose worker was already up (but the page didn't know) no longer draws
43
+ that worker's earlier turns a second time: the page re-reads the session instead.
44
+ - Web without the session events stream (fetched list): a re-fetched list now badges and notifies like the
45
+ live list does.
46
+ - Web: a session deleted while the page had lost the session events stream (or between two fetches of the
47
+ list) no longer keeps its question or card counted in the tab title's badge until the tab comes to front.
48
+ - Web on a phone: the session footer's archive, stop and delete buttons are icons (the words stay as their
49
+ accessible names and tooltips), so the status and `mem N` chips beside them are in sight again.
50
+ - Web: with several chi tabs in the background, a session that needs you shows one OS notification, not one
51
+ per tab (every tab still counts it in its title).
52
+ - chi web: a session stream whose worker took the connection and froze before answering no longer holds the
53
+ request forever; it gives up after 5 s per attempt.
54
+
55
+ Update with `chi update`: loop-guard moves to 0.3.1 (short-sentence loops, forget_after, the blind-watch fix). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
56
+
11
57
  ## [0.20.0] - 2026-10-03
12
58
 
13
59
  ### Added
@@ -1199,7 +1245,8 @@ and long-lived sessions.
1199
1245
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1200
1246
  hotkey panel that send selected text or the clipboard to your sessions.
1201
1247
 
1202
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.20.0...HEAD
1248
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.21.0...HEAD
1249
+ [0.21.0]: https://github.com/dm1try/samagotchi/compare/v0.20.0...v0.21.0
1203
1250
  [0.20.0]: https://github.com/dm1try/samagotchi/compare/v0.19.0...v0.20.0
1204
1251
  [0.19.0]: https://github.com/dm1try/samagotchi/compare/v0.18.1...v0.19.0
1205
1252
  [0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
data/docs/architecture.md CHANGED
@@ -170,7 +170,8 @@ higher-level events so UIs get clean turn boundaries without inferring them:
170
170
 
171
171
  - `:turn_started` — `{ session_id:, prompt: }`
172
172
  - `:turn_completed` — `{ result: }` (the final `LLM::ModelResult`)
173
- - `:turn_canceled` — `{ cancellation_reason: }`
173
+ - `:turn_canceled` — `{ cancellation_reason:, cancelled_by: }` (`cancelled_by`: the bundle or hook label that
174
+ stopped it, for `cancellation_reason: :hook`; else nil)
174
175
 
175
176
  Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
176
177
  rescues sink errors). A new UI (web, API) supplies its own `on_event` and
data/docs/cli.md CHANGED
@@ -844,7 +844,8 @@ Behavior notes:
844
844
  `partial progress kept; !rollback restores the pre-turn state` under it (a canceled continue is back where it
845
845
  started). A failed turn ends with `✕ turn failed: <summary> · 2.0s` and a dim `prompt restored for retry`. The
846
846
  REPL and attached mode say the same; the web says `✕ canceled (Ctrl-C)` (`stopped` for its Stop button,
847
- `by a hook` for a hook's).
847
+ `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `✕ turn stopped by loop-guard` (the web:
848
+ `✕ stopped by loop-guard`).
848
849
  - Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
849
850
  message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
850
851
  tool call are dropped.
data/docs/plugins.md CHANGED
@@ -831,16 +831,22 @@ minutes, until the provider's output cap ends it with no answer. loop-guard
831
831
  watches the thinking while it streams ([Watching the
832
832
  stream](hooks.md#watching-the-stream)) and cuts it:
833
833
 
834
- - The thinking is cut into sentences; short ones (under `min_words`) are
835
- skipped. Two sentences count as the same when their words and word pairs
836
- mostly match (`similarity`), so a reworded round ("Hmm, …", a synonym, a
837
- swapped clause) still counts.
834
+ - The thinking is cut into sentences. Two sentences of `min_words` or more
835
+ count as the same when their words and word pairs mostly match
836
+ (`similarity`), so a reworded round ("Hmm, …", a synonym, a swapped
837
+ clause) still counts.
838
838
  - A loop is a cycle of 1 to `max_period` sentences seen `repeats` times in a
839
839
  row, over at least `min_span_sentences` sentences and `min_span_chars`
840
840
  chars; or one sentence `max_same` times anywhere in the generation. One
841
841
  sentence over and over must be near-identical (`similarity` + 0.4), and a
842
842
  longer cycle must hold different sentences: a list of templated sentences
843
843
  ("Now I need to open the file <path> and …" for 14 paths) is no loop.
844
+ - Short sentences (under `min_words`) are a loop of their own when
845
+ `short_run` of them come in a row with at most `short_distinct` different
846
+ ones among them ("I'll write it. Go. OK. Writing. Go. OK."). Normal
847
+ thinking has short sentences too ("Hmm.", "Fine."), but spread out or all
848
+ different (code lines); a longer sentence ends the run, and lines inside a
849
+ ```` ``` ```` code block don't count.
844
850
  - Nothing triggers before `min_chars` of thinking. The watch sees a loop
845
851
  within one batch (2000 chars, or a second) of its third cycle.
846
852
 
@@ -851,11 +857,16 @@ What happens (`action: retry`, the default):
851
857
  `loop-guard> thinking repeats itself (3 sentences ×3, 4k chars, 8 s): cut`
852
858
  and `↻ cut by loop-guard, asking again (1/1)`.
853
859
  2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
854
- turn canceled (hook)") with a card that quotes the repeated sentences.
860
+ turn stopped by loop-guard") with a card that quotes the repeated
861
+ sentences.
862
+ 3. A loop is forgotten after `forget_after` good steps in a row (generations
863
+ with no loop): a model that recovered and made progress gets its next
864
+ loop cut and retried again, not the turn stopped. `forget_after: 0`
865
+ never forgets: the turn's second loop stops it.
855
866
 
856
867
  The cut uses the `retry.empty_answer` budget: with `retry.empty_answer: 0`
857
- the first loop ends the turn as cancelled (hook), with the notice and no
858
- card. `action: stop` stops the turn at the first loop; `action: notify` only
868
+ the first loop ends the turn as stopped by loop-guard, with the notice and
869
+ no card. `action: stop` stops the turn at the first loop; `action: notify` only
859
870
  warns, once per generation.
860
871
 
861
872
  ```yaml
@@ -871,7 +882,10 @@ bundles:
871
882
  min_span_sentences: 6 # a loop spans at least this many sentences…
872
883
  min_span_chars: 600 # …and this many chars
873
884
  max_same: 8 # one sentence this many times in one generation is a loop
874
- min_words: 5 # shorter sentences are ignored
885
+ min_words: 5 # shorter sentences only count toward a short run
886
+ short_run: 24 # this many short sentences in a row…
887
+ short_distinct: 6 # …with at most this many different ones is a loop
888
+ forget_after: 10 # good steps in a row that forget a loop; 0: never
875
889
  ```
876
890
 
877
891
  Not watched:
data/docs/sessions.md CHANGED
@@ -15,7 +15,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
15
15
 
16
16
  **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
17
17
 
18
- **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session was empty, so it is discarded.` The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
18
+ **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
19
19
 
20
20
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
21
21
 
@@ -25,7 +25,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
25
25
 
26
26
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
27
27
 
28
- <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
28
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
29
29
 
30
30
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
31
31
 
@@ -39,6 +39,8 @@ module Samagotchi
39
39
  class SSEWriter
40
40
  DEFAULT_MAX_QUEUE = 1024
41
41
  DEFAULT_HEARTBEAT_INTERVAL = 15.0
42
+ # Queued by the hang-up watcher (#watch_hangup) to wake #drain!.
43
+ HANGUP = Object.new.freeze
42
44
 
43
45
  # @param engine [Samagotchi::Engine] the owning engine (live fan-out)
44
46
  # @param ring [Samagotchi::Bridge::RingBuffer] shared capture buffer
@@ -77,6 +79,7 @@ module Samagotchi
77
79
  @foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
78
80
  @high_water = @foreign_cursor ? 0 : cursor_seq
79
81
  @handle = nil
82
+ @hung_up = false
80
83
  end
81
84
 
82
85
  # Non-blocking enqueue used as the per-connection live observer. Any
@@ -97,6 +100,7 @@ module Samagotchi
97
100
  # the client disconnects or the bridge stops.
98
101
  def serve!(io)
99
102
  write_sse_headers(io)
103
+ watcher = watch_hangup(io)
100
104
  begin
101
105
  if @join_with_snapshot
102
106
  # Subscribe and snapshot as one step of the event log, then go live.
@@ -119,11 +123,36 @@ module Samagotchi
119
123
  # Client hung up; nothing to do.
120
124
  ensure
121
125
  @engine.unsubscribe(handle: @handle) if @handle
126
+ watcher&.kill
122
127
  end
123
128
  end
124
129
 
125
130
  private
126
131
 
132
+ # A client that hangs up is noticed now, not at the next write (a
133
+ # heartbeat, up to its interval away): the Bridge stops counting its
134
+ # stream (Bridge#open_streams_except), so an attached /exit from the
135
+ # TUI attached after it isn't held by it. A thread waits for the
136
+ # socket to read EOF and wakes #drain!. A client sends nothing after
137
+ # its request; bytes it does send are dropped. Real sockets only.
138
+ def watch_hangup(io)
139
+ return nil unless io.is_a?(IO)
140
+
141
+ Thread.new do
142
+ loop do
143
+ case io.read_nonblock(4096, exception: false)
144
+ when nil then break
145
+ when :wait_readable then io.wait_readable
146
+ end
147
+ end
148
+ rescue IOError, SystemCallError
149
+ nil
150
+ ensure
151
+ @hung_up = true
152
+ @queue.push(HANGUP)
153
+ end.tap { |thread| thread.report_on_exception = false }
154
+ end
155
+
127
156
  def replay!(io, snapshot_seq)
128
157
  from_seq = @high_water
129
158
  oldest = @ring.oldest_seq
@@ -146,6 +175,9 @@ module Samagotchi
146
175
  def drain!(io)
147
176
  loop do
148
177
  payload = @queue.pop(@heartbeat_interval)
178
+ # The watcher's flag, not only its HANGUP: an overflow can drop that.
179
+ break if @hung_up
180
+
149
181
  if payload
150
182
  if payload.is_a?(Hash) && payload[:sse_reset]
151
183
  emit_reset(io)
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  end
39
39
 
40
40
  # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
41
- # continue, ordered parts (thinking / text / tool / input / steer /
42
- # reminder / notice), pending_question and the last event_seq folded
41
+ # continue, ordered parts (generation / thinking / text / tool /
42
+ # input / steer / reminder / notice), pending_question and the last event_seq folded
43
43
  # in. A finished tool part keeps the live row's action and duration_ms
44
44
  # too, a running one the time it has run so far (elapsed_ms), so a
45
45
  # join times its row from its start. A notice part holds the event
@@ -101,7 +101,9 @@ module Samagotchi
101
101
  # The turn in progress (#current_turn's shape, symbol keys) as the live
102
102
  # events that would have drawn it, in order, as turn_events.js
103
103
  # snapshotEvents replays it for the web (spec/shared/turn_snapshot.json
104
- # pins both): a generation_chunk per thinking or text part (a
104
+ # pins both): a generation_started per generation part (a join during
105
+ # a hold shows the step the model is on), a generation_chunk per
106
+ # thinking or text part (a
105
107
  # generation_completed closes text before the next step's), a tool's
106
108
  # tool_call_started and, once finished, its tool_call_completed (with
107
109
  # the live activity: action, tool, params, status), a merged_input
@@ -123,6 +125,9 @@ module Samagotchi
123
125
  end
124
126
  Array(turn[:parts]).each do |part|
125
127
  case part[:kind]
128
+ when "generation"
129
+ close_text.call
130
+ events << { type: :generation_started, iteration: part[:iteration] }
126
131
  when "thinking" then events << { type: :generation_chunk, text: "", thinking: part[:text], iteration: part[:iteration] }
127
132
  when "text"
128
133
  close_text.call if !text_iteration.nil? && text_iteration != part[:iteration]
@@ -233,6 +238,8 @@ module Samagotchi
233
238
  def fold_turn_event(event)
234
239
  parts = @turn[:parts]
235
240
  case event[:type]
241
+ when :generation_started
242
+ parts << { kind: "generation", iteration: event[:iteration] }
236
243
  when :generation_chunk
237
244
  if event.key?(:text)
238
245
  # Profile-split stream: thinking and visible text separately.
@@ -134,7 +134,8 @@ module Samagotchi
134
134
 
135
135
  # Streams held by anyone but +client_id+: a client's own stream (or two,
136
136
  # while it reconnects and the old one isn't noticed dead yet) doesn't
137
- # count. A closed stream counts until its next write fails (heartbeat).
137
+ # count. A closed stream stops counting once its writer reads the
138
+ # hang-up (SSEWriter#watch_hangup).
138
139
  # @return [Integer]
139
140
  def open_streams_except(client_id)
140
141
  @mutex.synchronize { @open_streams - (client_id ? @streams_by_client[client_id] : 0) }
@@ -698,7 +699,10 @@ module Samagotchi
698
699
  # event log held, so no POST /turn lands between its check and its
699
700
  # answer; it leaves from its loop after this reply.
700
701
  # 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
701
- # {status: "held", reason:} naming what keeps it up. Returns [headers, status, body].
702
+ # {status: "held", reason:} naming what keeps it up. A past +deadline+
703
+ # (see #handle_post_turn), checked with the event log held: 408
704
+ # deadline_passed, the worker not asked (its client said it didn't stop).
705
+ # Returns [headers, status, body].
702
706
  def handle_exit_request(session_id, body)
703
707
  return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
704
708
 
@@ -709,7 +713,15 @@ module Samagotchi
709
713
  delete = fetched(parsed, "delete") == true
710
714
  restart = fetched(parsed, "restart") == true
711
715
  options = restart ? { delete: delete, restart: true } : { delete: delete }
712
- reason = @engine.synchronize_events { @on_exit_request.call(client_id, **options) }
716
+ deadline = fetched(parsed, "deadline")
717
+ return BAD_DEADLINE unless deadline_valid?(deadline)
718
+
719
+ reason = @engine.synchronize_events do
720
+ next :expired if expired?("exit_expired", deadline, sid: session_id, client_id: client_id)
721
+
722
+ @on_exit_request.call(client_id, **options)
723
+ end
724
+ return deadline_passed("exit request") if reason == :expired
713
725
  if reason.nil?
714
726
  return [{}, 200, { status: "restarting", session_id: @session_id }] if restart
715
727
 
@@ -849,7 +861,7 @@ module Samagotchi
849
861
  end
850
862
 
851
863
  # The 408 for a request read after its deadline: +what+ (turn, command,
852
- # answer, dismissal) was dropped.
864
+ # answer, dismissal, exit request) was dropped.
853
865
  def deadline_passed(what)
854
866
  [{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
855
867
  end
@@ -20,6 +20,10 @@ module Samagotchi
20
20
  # A reply's status line; its code is group 1.
21
21
  STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
22
22
  STREAM_CONNECT_ATTEMPTS = 3
23
+ # Seconds #stream waits for the Bridge's response headers, per attempt:
24
+ # a worker that took the connection and froze must not hold the web's
25
+ # proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
26
+ STREAM_HEADER_TIMEOUT = 5.0
23
27
  # Seconds #stream waits for bytes before it asks `running` again.
24
28
  STREAM_POLL = 0.5
25
29
  # Seconds a one-shot request waits for the whole reply. Every route
@@ -28,7 +32,7 @@ module Samagotchi
28
32
  # raises Errno::ECONNREFUSED. The event stream has no such limit.
29
33
  READ_TIMEOUT = 30
30
34
  # Share of the read timeout a request's deadline allows (25 s of 30): the
31
- # Bridge drops a turn, command, answer or dismissal it reads after the
35
+ # Bridge drops a turn, command, answer, dismissal or exit it reads after the
32
36
  # deadline, so one this client timed out on, and said did not go
33
37
  # through, never runs when a frozen worker wakes. The rest covers the
34
38
  # write and the reply.
@@ -159,13 +163,14 @@ module Samagotchi
159
163
  # POST /session/:id/exit: ask the worker to exit now (+restart+: hand
160
164
  # the session to a new worker, a worker with the "restart" feature). 200
161
165
  # = it will (status "exiting"/"restarting"), 409 = something keeps it up (reason), 404 with
162
- # error "not_found" = a worker older than the route.
166
+ # error "not_found" = a worker older than the route, 408 = too late.
163
167
  # @param client_id [String] the asking UI, whose own streams don't hold
164
168
  # @return [Response]
165
169
  def request_exit(client_id:, delete: false, restart: false)
166
170
  body = { client_id: client_id }
167
171
  body[:delete] = true if delete
168
172
  body[:restart] = true if restart
173
+ body[:deadline] = deadline
169
174
  post("exit", body, read_body: true)
170
175
  end
171
176
 
@@ -258,7 +263,8 @@ module Samagotchi
258
263
  # @param running [#call, nil] asked before every read, and every
259
264
  # STREAM_POLL seconds of silence: the stream ends once it returns
260
265
  # false. A quiet stream is never cut otherwise.
261
- def stream(query: "", last_event_id: nil, running: nil)
266
+ # @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
267
+ def stream(query: "", last_event_id: nil, running: nil, header_timeout: STREAM_HEADER_TIMEOUT)
262
268
  sock = nil
263
269
  # Absorb the probe→connect race around a resumed worker's bridge:
264
270
  # the sidecar probe can succeed a moment before the worker dies (or
@@ -266,7 +272,7 @@ module Samagotchi
266
272
  # avoid the silent empty-200 that EventSource would otherwise keep
267
273
  # re-opening.
268
274
  STREAM_CONNECT_ATTEMPTS.times do |attempt|
269
- sock, = connect_stream(query: query, last_event_id: last_event_id)
275
+ sock, = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
270
276
  break if sock
271
277
 
272
278
  sleep(0.15 * (attempt + 1))
@@ -302,7 +308,7 @@ module Samagotchi
302
308
  end
303
309
 
304
310
  # Open GET /session/:id/stream and read past the response headers.
305
- # @param timeout [Float, nil] give up when the headers don't start in time
311
+ # @param timeout [Float, nil] give up when the headers aren't all in by then
306
312
  # @return [Array(TCPSocket, Integer), nil] the socket positioned at the
307
313
  # body and the HTTP status, or nil when the Bridge can't be reached
308
314
  def connect_stream(query: "", last_event_id: nil, timeout: nil)
@@ -310,10 +316,9 @@ module Samagotchi
310
316
  lei = last_event_id.to_s.strip
311
317
  last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
312
318
  sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
313
- raise Errno::ETIMEDOUT if timeout && !sock.wait_readable(timeout)
314
-
315
- status = sock.gets.to_s[STATUS_LINE, 1].to_i
316
- while (line = sock.gets)
319
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
320
+ status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
321
+ while (line = header_line(sock, deadline))
317
322
  break if line.strip.empty?
318
323
  end
319
324
  [sock, status]
@@ -324,6 +329,25 @@ module Samagotchi
324
329
 
325
330
  private
326
331
 
332
+ # One line of the response headers, or nil at EOF; raises
333
+ # Errno::ETIMEDOUT once +deadline+ (monotonic, nil: none) has passed. A
334
+ # line read in pieces, so a Bridge that stops mid-line can't hold it.
335
+ def header_line(sock, deadline)
336
+ return sock.gets unless deadline
337
+
338
+ line = +""
339
+ loop do
340
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
341
+ raise Errno::ETIMEDOUT unless remaining.positive? && sock.wait_readable(remaining)
342
+
343
+ char = sock.getc
344
+ return line.empty? ? nil : line if char.nil?
345
+
346
+ line << char
347
+ return line if char == "\n"
348
+ end
349
+ end
350
+
327
351
  # When this client stops waiting for a reply, as epoch seconds (see
328
352
  # DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
329
353
  # the same one.
@@ -200,6 +200,10 @@ module Samagotchi
200
200
  end
201
201
 
202
202
  # Runs +installer+, prints its summary, then yields the manifest.
203
+ # Ensures the extracted source dir is cleaned up even if an exception
204
+ # occurs between installer.run and the block (e.g. summary,
205
+ # print_hooks_and_plugin). The block (upgrade_conflicts) still sees
206
+ # the files because cleanup happens after the block returns or raises.
203
207
  # @return [Integer] the block's exit status, or 1 after "<failed> failed: …"
204
208
  def run_installer(installer, failed:)
205
209
  _nd, manifest = installer.run
@@ -208,6 +212,8 @@ module Samagotchi
208
212
  rescue Samagotchi::MemoryBundle::Installer::InstallError => e
209
213
  @stderr.puts "#{failed} failed: #{e.message}"
210
214
  1
215
+ ensure
216
+ installer.cleanup_source!
211
217
  end
212
218
 
213
219
  def print_hooks_and_plugin(manifest, plugin: true)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.2.2
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:e62c68d1a2280adf55b150488d0d85bbbc35ffce54dfed387a86adceb85e4d34
9
+ sha256: sha256:32d04fa3f4b3394e80160dd780615a4a1ddeb3be0f2e327a6497c7766dbd9f11
10
10
  requires_chi: ">= 0.6.0"
@@ -24,6 +24,9 @@
24
24
  # thinking: the thinking watch (ThinkingWatch::DEFAULTS)
25
25
  # watch: true false: the tool-call guard only
26
26
  # action: retry retry (cut, ask again; then stop) | stop | notify
27
+ # forget_after: 10 good steps (generations with no loop) in a row after a
28
+ # cut loop, and the next loop is cut again, not stopped;
29
+ # 0: never forget, the turn's second loop stops it
27
30
  require "digest"
28
31
 
29
32
  # Sees thinking repeat itself, from the deltas of one generation.
@@ -44,12 +47,19 @@ require "digest"
44
47
  # of 2 or more needs a cycle of distinct sentences (not all alike), and a
45
48
  # period of 1 (one sentence over and over) needs `similarity` +
46
49
  # SAME_MARGIN (0.9 by default: near-identical);
47
- # - the same sentence max_same times in the generation, in any order.
50
+ # - the same sentence max_same times in the generation, in any order;
51
+ # - a short run: short_run short sentences (under min_words) in a row, at
52
+ # most short_distinct different ones among the last short_run ("I'll
53
+ # write it. Go. OK. Writing. Go."). Normal thinking has short sentences
54
+ # too ("Hmm.", "Fine."), but spread out, or in runs of different ones
55
+ # (code lines); a sentence of min_words or more ends the run, one with no
56
+ # letters or digits neither counts nor ends it. Lines inside a ``` code
57
+ # block are not short sentences.
48
58
  # Nothing triggers before min_chars of thinking. O(new chars) per feed.
49
59
  class ThinkingWatch
50
60
  DEFAULTS = { "watch" => true, "action" => "retry", "min_chars" => 2000, "repeats" => 3, "max_period" => 6,
51
61
  "similarity" => 0.5, "min_span_sentences" => 6, "min_span_chars" => 600, "max_same" => 8,
52
- "min_words" => 5 }.freeze
62
+ "min_words" => 5, "short_run" => 24, "short_distinct" => 6, "forget_after" => 10 }.freeze
53
63
  ACTIONS = %w[retry stop notify].freeze
54
64
  MAX_CARRY = 400
55
65
  # One sentence again and again needs this much more alike than a cycle.
@@ -68,10 +78,13 @@ class ThinkingWatch
68
78
  out = DEFAULTS.dup
69
79
  out["watch"] = !%w[false no off 0].include?(raw["watch"].to_s.downcase) if raw.key?("watch")
70
80
  out["action"] = raw["action"].to_s if ACTIONS.include?(raw["action"].to_s)
71
- %w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words].each do |key|
81
+ %w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words short_run
82
+ short_distinct].each do |key|
72
83
  number = Integer(raw[key].to_s, exception: false)
73
84
  out[key] = number if number&.positive?
74
85
  end
86
+ forget = Integer(raw["forget_after"].to_s, exception: false)
87
+ out["forget_after"] = forget if forget && forget >= 0
75
88
  similarity = Float(raw["similarity"].to_s, exception: false)
76
89
  out["similarity"] = similarity if similarity && similarity.positive? && similarity <= 1
77
90
  out["repeats"] = 2 if out["repeats"] < 2
@@ -87,12 +100,17 @@ class ThinkingWatch
87
100
  @min_span_chars = settings["min_span_chars"]
88
101
  @max_same = settings["max_same"]
89
102
  @min_words = settings["min_words"]
103
+ @short_run = settings["short_run"]
104
+ @short_distinct = settings["short_distinct"]
90
105
  @same_similarity = [@similarity + SAME_MARGIN, 0.95].min
91
106
  @carry = +""
92
107
  @window = [] # the last max_period + 1 sentences
93
108
  @run = Array.new(@max_period + 1, 0)
94
109
  @run_chars = Array.new(@max_period + 1, 0) # chars of the run's sentences plus the first cycle's
95
110
  @counts = Hash.new(0)
111
+ @short_count = 0 # short sentences in a row
112
+ @short_window = [] # the last short_run of them, [key, text]
113
+ @in_code = false
96
114
  @thinking_chars = 0
97
115
  end
98
116
 
@@ -115,7 +133,9 @@ class ThinkingWatch
115
133
  # The complete sentences in the carry; the unfinished tail stays.
116
134
  def sentences
117
135
  parts = @carry.split(SPLIT, -1)
118
- @carry = parts.pop.to_s
136
+ # An empty carry splits into no parts: nil.to_s is frozen, and the next
137
+ # feed's << would raise (the watch went blind for that generation).
138
+ @carry = +parts.pop.to_s
119
139
  if @carry.length > MAX_CARRY
120
140
  cut = @carry.rindex(" ", MAX_CARRY) || MAX_CARRY
121
141
  parts << @carry[0, cut]
@@ -125,8 +145,13 @@ class ThinkingWatch
125
145
  end
126
146
 
127
147
  def add(text)
148
+ if text.strip.start_with?("```")
149
+ @in_code = !@in_code
150
+ return nil
151
+ end
128
152
  words = text.downcase.gsub(/[^[:alnum:]]+/, " ").split
129
- return nil if words.length < @min_words
153
+ short = short_loop(text.strip, words) unless @in_code
154
+ return (short if @thinking_chars >= @min_chars) if words.length < @min_words
130
155
 
131
156
  key = words.join(" ")
132
157
  sentence = Sentence.new(text: text.strip, words: words.map(&:hash).uniq,
@@ -140,6 +165,25 @@ class ThinkingWatch
140
165
  cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil)
141
166
  end
142
167
 
168
+ def short_loop(text, words)
169
+ return nil if words.empty?
170
+
171
+ if words.length >= @min_words
172
+ @short_count = 0
173
+ @short_window.clear
174
+ return nil
175
+ end
176
+ @short_count += 1
177
+ @short_window << [words.join(" "), text]
178
+ @short_window.shift if @short_window.length > @short_run
179
+ return nil if @short_window.length < @short_run
180
+
181
+ distinct = @short_window.uniq(&:first)
182
+ return nil if distinct.length > @short_distinct
183
+
184
+ Loop.new(period: distinct.length, times: @short_count / distinct.length, sentences: distinct.first(3).map(&:last), chars: nil)
185
+ end
186
+
143
187
  def count(key)
144
188
  return @counts[key] += 1 if @counts.key?(key) || @counts.size < MAX_DISTINCT
145
189
 
@@ -209,6 +253,7 @@ class Plugin
209
253
  return unless @thinking["watch"]
210
254
 
211
255
  chi.on(:before_generation) do |_event|
256
+ good_step if @watch
212
257
  @watch = ThinkingWatch.new(@thinking)
213
258
  @watch_done = false
214
259
  end
@@ -242,6 +287,16 @@ class Plugin
242
287
  end
243
288
  end
244
289
 
290
+ # The generation before this one ended with no loop: a good step. After
291
+ # forget_after of them in a row the turn's loops are forgotten, so a loop
292
+ # long after the model recovered is cut and retried, not stopped.
293
+ def good_step
294
+ return @good_steps = 0 if @watch_done
295
+
296
+ @good_steps += 1
297
+ @thinking_loops = 0 if @good_steps == @thinking["forget_after"]
298
+ end
299
+
245
300
  def stop_thinking(ctx, found, what)
246
301
  return unless ctx.stop_turn("the model's thinking kept repeating itself (#{what})")
247
302
 
@@ -261,6 +316,7 @@ class Plugin
261
316
  @denials = 0
262
317
  @pending = nil
263
318
  @thinking_loops = 0
319
+ @good_steps = 0
264
320
  @watch = nil
265
321
  end
266
322