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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +2 -1
- data/docs/plugins.md +22 -8
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +6 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +15 -2
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +6 -4
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +34 -7
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/timing.js +5 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9d794c2749a0f1293cb16d7afeb7e023f38dd8798532e4dcb2d0e9fcb575af1
|
|
4
|
+
data.tar.gz: 8ade3692c344214cc8cdbe90c1fb8539242639f7cbf8707c751a8d2b02f9548a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 /
|
|
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
|
|
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.
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|