samagotchi 0.49.0 → 0.50.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 +41 -1
- data/bin/chi +2 -2
- data/docs/cli.md +12 -1
- data/docs/hooks.md +9 -1
- data/docs/sessions.md +4 -1
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/hooks/stream_watch.rb +9 -2
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/send_command.rb +61 -0
- data/lib/samagotchi/session_commands.rb +1 -2
- data/lib/samagotchi/session_metrics.rb +7 -0
- data/lib/samagotchi/sessions_command.rb +84 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +17 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +5 -1
- data/lib/samagotchi/web/public/live_timing.js +32 -5
- data/lib/samagotchi/web/public/stage_model.js +15 -0
- data/lib/samagotchi/web/public/stage_view.js +10 -4
- data/lib/samagotchi/web/public/timing.js +11 -0
- data/lib/samagotchi/web/public/turn_events.js +11 -0
- data/lib/samagotchi/web/public/turn_html.js +21 -0
- data/lib/samagotchi/web/public/turn_view.js +9 -3
- data/lib/samagotchi/web/session_hub.rb +11 -3
- 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: a34348a50eeb261495c985e51195fa87b9f04121e56852083b014229bf05323a
|
|
4
|
+
data.tar.gz: 4bc36375cf4eb7bfba24212c080b86759026c29e17bae2adae653f0d2f7e88f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68eea3684c38f7ca72ab13898f461673e1ddb817dce4a6f9dc250ba41230e9e526601ed8c632b58cdba2dce5b5f6fbdb5559da4a6ffa5971680096d36e6973b5
|
|
7
|
+
data.tar.gz: ecc35d61cb8b8560e688fcda868c91135c2f1a1662f649f5a21105c912614a2211d240ea39d3f15bb56500d52b98743decd2c0d5a13eab4f43ba68962076bd1b
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,45 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.50.0] - 2026-10-09
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `chi sessions stats ID [--format text|json]`: a session's cost, tokens and progress without running a model turn:
|
|
16
|
+
a live worker's `GET stats` (waiting up to 3 s), else the snapshot its saved `analytics.json` rebuilds (never
|
|
17
|
+
starting a worker); text prints the `/stats` report headed by `<id8> <status> <model>`, json one
|
|
18
|
+
`{session_id, status, live, metrics}` object.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- A turn record in `analytics.json` says whether the turn was a step-limit Continue (`continue: true`, else `false`),
|
|
23
|
+
and the model-notes report counts Continues from that mark when the file has it, instead of the older guess (its
|
|
24
|
+
turn records with no prompt of their own), which also counted reminder turns and prompts `!rollback` erased.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- TUI: a tool row whose output the runner cut says so with a dim `[cut]`, as the web's row already marks it.
|
|
29
|
+
- `/models` is listed as an anytime command (`mid-turn too`) in `/help` and the web's command list, not the
|
|
30
|
+
per-line `depends` label it got while it always runs mid-turn.
|
|
31
|
+
- Web: a host added to `hosts:` (or a `hosts.<name>.models` context window changed) in config.yml while `chi web`
|
|
32
|
+
runs now reaches the session cards' ctx % on their next refresh, not only after a restart; the hub's host registry
|
|
33
|
+
is rebuilt when `hosts:` changes, as the model picker's already was.
|
|
34
|
+
- A host listing saved to the state dir when the registry was built with its own env (the spawn-time model check, the
|
|
35
|
+
self report) writes under that env's state dir, not `ENV`'s.
|
|
36
|
+
- loop-guard: a dropped stream's step asked again (it streams from the start) no longer reads the dropped attempt's
|
|
37
|
+
thinking as part of the new one — the model re-thinking the same opening after a restart could be cut or stopped
|
|
38
|
+
as "thinking repeats itself". The first `:generation_progress` fire after such a restart carries `restarted: true`,
|
|
39
|
+
and the loop-guard thinking watch starts over on it (loop-guard 0.3.7).
|
|
40
|
+
- Web: the live timing line counts up from the first second (tenths of a second while under 10 s, ticking every
|
|
41
|
+
200 ms there) instead of reading "0ms" for the turn's first second.
|
|
42
|
+
- Web: the stage trail's items hover their whole text — a flash (a hook notice) its whole line, a call its full
|
|
43
|
+
command when it has one, else its whole truncated text.
|
|
44
|
+
- Web: a `!cmd`'s output shows once, as a command bubble: the history renders the saved `!(<command>)` message as
|
|
45
|
+
one (not a user bubble), and a live `command_ran` no longer adds a second bubble after the redraw.
|
|
46
|
+
- `chi send` refuses a UI-only command (`/stats`, `/exit`, `/recap`, …) instead of passing it to the worker, which
|
|
47
|
+
doesn't run it, so the line reached the model as a prompt and cost a whole turn; the refusal names
|
|
48
|
+
`chi sessions stats` for `/stats` and sends nothing (exit 1, also with `--new` before a session is created).
|
|
49
|
+
|
|
11
50
|
## [0.49.0] - 2026-10-09
|
|
12
51
|
|
|
13
52
|
### Added
|
|
@@ -2262,7 +2301,8 @@ and long-lived sessions.
|
|
|
2262
2301
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
2263
2302
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
2264
2303
|
|
|
2265
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
2304
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.50.0...HEAD
|
|
2305
|
+
[0.50.0]: https://github.com/dm1try/samagotchi/compare/v0.49.0...v0.50.0
|
|
2266
2306
|
[0.49.0]: https://github.com/dm1try/samagotchi/compare/v0.48.0...v0.49.0
|
|
2267
2307
|
[0.48.0]: https://github.com/dm1try/samagotchi/compare/v0.47.0...v0.48.0
|
|
2268
2308
|
[0.47.0]: https://github.com/dm1try/samagotchi/compare/v0.46.1...v0.47.0
|
data/bin/chi
CHANGED
|
@@ -36,7 +36,7 @@ end
|
|
|
36
36
|
|
|
37
37
|
# Subcommands with their own flags, dispatched before OptionParser.
|
|
38
38
|
SUBCOMMANDS = {
|
|
39
|
-
# list, stop, archive/unarchive, delete, prune, clean (--dry-run, --days, …)
|
|
39
|
+
# list, stop, archive/unarchive, delete, prune, clean, stats (--dry-run, --days, …)
|
|
40
40
|
"sessions" => ["samagotchi/sessions_command", "SessionsCommand"],
|
|
41
41
|
# a context note for sessions (-m, --source, --all)
|
|
42
42
|
"note" => ["samagotchi/note_command", "NoteCommand"],
|
|
@@ -95,7 +95,7 @@ parser = OptionParser.new do |opts|
|
|
|
95
95
|
chi bootstrap [HOST[:PORT]|URL] first setup: find the model server, write config.yml
|
|
96
96
|
chi scratch [options] a one-time session in this terminal: nothing is kept
|
|
97
97
|
chi web [--port PORT] [--open] [--scope=all] the web UI
|
|
98
|
-
chi sessions <list|stop|archive|unarchive|delete|prune|clean>
|
|
98
|
+
chi sessions <list|stop|archive|unarchive|delete|prune|clean|stats>
|
|
99
99
|
chi note [--source NAME] [-m TEXT] ID...|--all background context for sessions
|
|
100
100
|
chi broadcast [-m TEXT] [--all] [--dry-run] | log | deliver a note for every session it may concern
|
|
101
101
|
chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
|
data/docs/cli.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
- `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
|
|
16
16
|
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
|
|
17
17
|
- `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
|
|
18
|
-
- `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`) exits 2; an unknown or refused session exits 1
|
|
18
|
+
- `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`) exits 2; an unknown or refused session exits 1
|
|
19
19
|
- `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
|
|
20
20
|
- `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
|
|
21
21
|
- `chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one picks it up); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; `--format json` prints one JSON object instead; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
|
|
@@ -392,6 +392,17 @@ status: 0 when all are gone, 1 when any was refused or unknown, 2 on a usage
|
|
|
392
392
|
error. The Web UI deletes too: `delete` in the info bar, or the ✕ on a card in
|
|
393
393
|
All sessions; it asks first and stops a live worker.
|
|
394
394
|
|
|
395
|
+
`chi sessions stats ID [--format text|json]` prints a session's cost, tokens
|
|
396
|
+
and progress without running a model turn — the same text `/stats` prints in
|
|
397
|
+
a terminal, headed by `<id8> <status> <model>`: a live worker answers its
|
|
398
|
+
Bridge's `GET stats`, else the snapshot is rebuilt from the session's saved
|
|
399
|
+
`analytics.json` (never starting a worker; a worker that does not answer in
|
|
400
|
+
time falls back to the disk and the line says so). `--format json` prints one
|
|
401
|
+
object `{session_id, status, live, metrics}`; a session with no
|
|
402
|
+
`analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and
|
|
403
|
+
exits 0. An unknown or ambiguous id exits 1 with `chi sessions stop`'s
|
|
404
|
+
wording. See [Sessions](sessions.md#cli).
|
|
405
|
+
|
|
395
406
|
**The plain REPL.** Some launches run the session in this process instead, with
|
|
396
407
|
no worker:
|
|
397
408
|
|
data/docs/hooks.md
CHANGED
|
@@ -54,7 +54,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
54
54
|
| `:after_turn` | After a turn completed or was stopped once the model was asked (not after one that failed, was cut short by an interrupt signal (SIGINT), or was stopped before its `:before_turn` hooks) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
|
|
55
55
|
| `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
|
|
56
56
|
| `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
57
|
-
| `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
|
|
57
|
+
| `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
|
|
58
58
|
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
|
59
59
|
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars, a cut one ending in a [cut: N of M chars …] line), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
|
|
60
60
|
| `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
|
|
@@ -173,6 +173,14 @@ thinking arrives as `text` instead: a chat provider's that puts `<think>`
|
|
|
173
173
|
inside the answer's content. Tool-call bodies are left out of `text`. With streaming off
|
|
174
174
|
(`stream: false` on a chat host) there are no chunks, so no fires.
|
|
175
175
|
|
|
176
|
+
When a stream drops mid-generation, the step is asked again and streams
|
|
177
|
+
from the start (a `:generation_retrying` event with `restarted: true`);
|
|
178
|
+
what the dropped attempt streamed is void. The first `:generation_progress`
|
|
179
|
+
fire after that carries `restarted: true`; later fires, and fires after a
|
|
180
|
+
normal start, carry no `restarted` key. A watcher of the stream starts
|
|
181
|
+
over on it: the thinking a dropped attempt streamed is no part of this
|
|
182
|
+
generation's, and counting it would read a re-thought opening as a repeat.
|
|
183
|
+
|
|
176
184
|
**Keep it fast.** The hook runs on the turn's thread, inside the HTTP read:
|
|
177
185
|
while it runs, tokens wait in the socket (nothing is lost). A hook over
|
|
178
186
|
100 ms logs `stream_hook_slow` (warn, with its label and the ms) once per
|
data/docs/sessions.md
CHANGED
|
@@ -40,8 +40,11 @@ chi sessions list --archived # archived sessions too, m
|
|
|
40
40
|
chi sessions delete [--force] ID... # for good; --force stops a live worker first
|
|
41
41
|
chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
42
42
|
chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
|
|
43
|
+
chi sessions stats ID [--format text|json] # cost, tokens and progress, without a model turn
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
`chi sessions stats ID` prints what `/stats` prints in a terminal, headed by `<id8> <status> <model>`: a live worker answers its Bridge's `GET stats` (no model call, and the command never starts a worker); without one, or when the worker does not answer in time, the same snapshot is rebuilt from the session's saved `analytics.json` (and the line says so when a worker was live). `--format json` prints one object `{session_id, status, live, metrics}` with the snapshot as `metrics`; a session with no `analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and still exits 0. An unknown or ambiguous id exits 1 with the same wording `chi sessions stop` uses.
|
|
47
|
+
|
|
45
48
|
A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
|
|
46
49
|
|
|
47
50
|
Examples:
|
|
@@ -158,7 +161,7 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
|
|
|
158
161
|
- A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
|
|
159
162
|
- Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
|
|
160
163
|
- `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
|
|
161
|
-
- A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
|
|
164
|
+
- A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. A UI-only command (`/stats`, `/exit`, `/recap`, …) is refused instead: the worker doesn't run it, so the line would reach the model as a prompt (`<id> refused: /stats is a terminal and web command; use chi sessions stats <id8>`; exit 1, and nothing is sent). With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
|
|
162
165
|
- One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
|
|
163
166
|
|
|
164
167
|
The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
|
data/docs/testing.md
CHANGED
|
@@ -80,7 +80,8 @@ sessions by model and by the model notes their prompt carried (the session file'
|
|
|
80
80
|
prints the numbers a model note is meant to move: the place of the first edit (an `edit` or `write` call) and of the
|
|
81
81
|
first commit (an `execute` call that runs `git [options] commit`) among the tool calls, commits per 100 steps (model
|
|
82
82
|
requests), `execute` calls with a bare `&`, the longest run of calls with no edit, and what the session needed from
|
|
83
|
-
outside: Continues (the turns in its `analytics.json`
|
|
83
|
+
outside: Continues (the turns in its `analytics.json` marked `continue: true` — the mark a file written after
|
|
84
|
+
2026-10-09 carries; an older file has none, and its turns with no prompt of their own are counted instead), steers (lines that reached a
|
|
84
85
|
running turn from the user, `chi send` or a parent, and a Continue's text), follow-up prompts and plugin nudges. A
|
|
85
86
|
group has the medians of the places and runs, commits per 100 steps pooled and the counts summed. `--help` defines
|
|
86
87
|
each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json` gives JSON. It reads only and prints
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loop-guard
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.7
|
|
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:e8c31f608f7cef5d605285efcc40ca32efd7fd60c862a39480b9edf61df56acc
|
|
10
10
|
requires_chi: ">= 0.6.0"
|
|
@@ -313,8 +313,20 @@ class Plugin
|
|
|
313
313
|
# A stretch of streamed thinking. The first loop in a turn is cut and the
|
|
314
314
|
# model asked again (action retry); a second one, or action stop, stops
|
|
315
315
|
# the turn with a card; action notify only warns, once per generation.
|
|
316
|
+
#
|
|
317
|
+
# A restarted: true fire is a dropped stream's step asked again: the
|
|
318
|
+
# model streams from the start, and what the dropped attempt's watch
|
|
319
|
+
# holds is void — a re-thought opening would repeat it and look like a
|
|
320
|
+
# loop. The watch starts over (a new one, before this fire's thinking);
|
|
321
|
+
# the restart is no good step, and a loop cut before it still counts.
|
|
316
322
|
def progress(event, ctx)
|
|
317
|
-
return if @watch.nil?
|
|
323
|
+
return if @watch.nil?
|
|
324
|
+
|
|
325
|
+
if event[:restarted]
|
|
326
|
+
@watch = ThinkingWatch.new(@thinking)
|
|
327
|
+
@watch_done = false
|
|
328
|
+
end
|
|
329
|
+
return if @watch_done
|
|
318
330
|
|
|
319
331
|
found = @watch.feed(event[:thinking])
|
|
320
332
|
return unless found
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -2570,7 +2570,7 @@ module Samagotchi
|
|
|
2570
2570
|
# A dropped stream's step asked again: it streams from the start.
|
|
2571
2571
|
if event[:restarted]
|
|
2572
2572
|
@generation_thought = nil
|
|
2573
|
-
watch&.started(event[:iteration])
|
|
2573
|
+
watch&.started(event[:iteration], restarted: true)
|
|
2574
2574
|
end
|
|
2575
2575
|
when :generation_chunk then note_generation_thought(progress[:thinking])
|
|
2576
2576
|
end
|
|
@@ -32,16 +32,20 @@ module Samagotchi
|
|
|
32
32
|
@clock = clock
|
|
33
33
|
@slow_logged = false
|
|
34
34
|
@iteration = nil
|
|
35
|
+
@restarted = false
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
# A new generation (:generation_started)
|
|
38
|
-
|
|
38
|
+
# A new generation (:generation_started), or a dropped stream's step
|
|
39
|
+
# asked again (restarted: true: the first fire after it carries
|
|
40
|
+
# restarted: true — what came before is void).
|
|
41
|
+
def started(iteration, restarted: false)
|
|
39
42
|
@iteration = iteration
|
|
40
43
|
@started_at = @last_fire_at = @clock.call
|
|
41
44
|
@thinking = +""
|
|
42
45
|
@text = +""
|
|
43
46
|
@thinking_chars = 0
|
|
44
47
|
@text_chars = 0
|
|
48
|
+
@restarted = restarted
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
# One chunk's new thinking and visible text.
|
|
@@ -70,6 +74,9 @@ module Samagotchi
|
|
|
70
74
|
elapsed_ms: ((now - @started_at) * 1000).round,
|
|
71
75
|
# A question would hold the HTTP read for minutes.
|
|
72
76
|
ask_user: ->(**) {} }
|
|
77
|
+
# The first fire after a restarted retry says so, once.
|
|
78
|
+
event[:restarted] = true if @restarted
|
|
79
|
+
@restarted = false
|
|
73
80
|
@thinking = +""
|
|
74
81
|
@text = +""
|
|
75
82
|
@last_fire_at = now
|
|
@@ -383,7 +383,7 @@ module Samagotchi
|
|
|
383
383
|
# a worker without listing (`chi send --new --model`, delegate):
|
|
384
384
|
# ModelListStore is what they check an id against.
|
|
385
385
|
def save_model_list(name, models)
|
|
386
|
-
ModelListStore.save(name, Array(models).map(&:id))
|
|
386
|
+
ModelListStore.save(name, Array(models).map(&:id), env: @env)
|
|
387
387
|
end
|
|
388
388
|
|
|
389
389
|
def fresh_list(name, entry)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
4
|
require_relative "session"
|
|
5
|
+
require_relative "session_commands"
|
|
5
6
|
require_relative "session_inbox"
|
|
6
7
|
require_relative "session_manager"
|
|
7
8
|
require_relative "session_chain"
|
|
@@ -175,6 +176,14 @@ module Samagotchi
|
|
|
175
176
|
@stderr.puts("chi send: #{e.message}")
|
|
176
177
|
return 1
|
|
177
178
|
end
|
|
179
|
+
# A UI-only command (/stats, /exit, …) runs in the terminal and the web,
|
|
180
|
+
# not in the worker: sent through, the worker doesn't run it and the
|
|
181
|
+
# line reaches the model as a prompt, costing a whole turn. Refused
|
|
182
|
+
# before anything is sent (before a --new session is created too); the
|
|
183
|
+
# session's plugin commands are never local.
|
|
184
|
+
if prompt && (local = local_command(prompt))
|
|
185
|
+
return refuse_local_command(local, options)
|
|
186
|
+
end
|
|
178
187
|
# A file that is not there or not an image: a run-time failure (1),
|
|
179
188
|
# reported in the --format json line too; --image itself was fine.
|
|
180
189
|
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
@@ -440,6 +449,58 @@ module Samagotchi
|
|
|
440
449
|
# (a bundle's too).
|
|
441
450
|
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
442
451
|
|
|
452
|
+
# The UI-only built-in command +text+ (stripped) is a line of (/stats,
|
|
453
|
+
# /exit, …), or nil: the worker never runs it, so chi send must not pass
|
|
454
|
+
# it on. What the UIs run: the line as the entry's match takes it — bare
|
|
455
|
+
# "exit" too (/exit's match), but not with arguments ("/exit --delete" is
|
|
456
|
+
# a UI line; "/stats please" is a prompt), as in a terminal.
|
|
457
|
+
# @return [Commands::Registry::Entry, nil]
|
|
458
|
+
def local_command(text)
|
|
459
|
+
line = text.to_s.strip
|
|
460
|
+
return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
|
|
461
|
+
|
|
462
|
+
entry = SessionCommands.builtin_registry.lookup_local(line)
|
|
463
|
+
# Only the exact line the UI runs: "/stats please" is a prompt, and
|
|
464
|
+
# "/exit --delete" is left to the worker (the TUI runs it as /exit, the
|
|
465
|
+
# worker answers its unknown-command way). Bare "exit" is /exit's line
|
|
466
|
+
# as its match takes it; the reply names the command, /exit.
|
|
467
|
+
return nil unless entry
|
|
468
|
+
return entry if line == entry.name
|
|
469
|
+
|
|
470
|
+
line.match?(/\Aexit\z/i) ? entry : nil
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# A UI-only command: nothing is sent (with --new, no session is created
|
|
474
|
+
# either). /stats points at its model-free reader and names the session;
|
|
475
|
+
# the others just say the worker doesn't run them. Exit 1, as other
|
|
476
|
+
# refusals; with --wait --format json the line is the usual error object
|
|
477
|
+
# (ParentWait's end-of-run line when the reply never comes).
|
|
478
|
+
# @return [Integer] 1
|
|
479
|
+
def refuse_local_command(entry, options)
|
|
480
|
+
if options[:new]
|
|
481
|
+
error_line("chi send: refused: #{local_refusal(entry.name, nil)}")
|
|
482
|
+
elsif options[:wait]
|
|
483
|
+
# --wait: the JSON line goes to stdout, @info is stderr; an unknown
|
|
484
|
+
# or ambiguous id gets resolve's own error line.
|
|
485
|
+
id = resolve(options[:ids].first) or return CLI::Exit::FAILED
|
|
486
|
+
@stderr.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
|
|
487
|
+
else
|
|
488
|
+
options[:ids].uniq.each do |given|
|
|
489
|
+
id = resolve(given) or next
|
|
490
|
+
@info.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
CLI::Exit::FAILED
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Why a UI-only command isn't sent: /stats points at its model-free
|
|
497
|
+
# reader (naming the session when there is one).
|
|
498
|
+
def local_refusal(name, id)
|
|
499
|
+
return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
|
|
500
|
+
|
|
501
|
+
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
502
|
+
end
|
|
503
|
+
|
|
443
504
|
# --wait after a message that ran as a session command: no reply comes
|
|
444
505
|
# for it. Its output shows where the session is open.
|
|
445
506
|
# @return [Integer] 0
|
|
@@ -71,8 +71,7 @@ module Samagotchi
|
|
|
71
71
|
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
|
|
72
72
|
end
|
|
73
73
|
# It reads the hosts only (the registry is mutex-guarded): mid-turn too.
|
|
74
|
-
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)",
|
|
75
|
-
mid_turn: ->(_text) { :anytime }) do |text|
|
|
74
|
+
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
|
|
76
75
|
reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
|
|
77
76
|
end
|
|
78
77
|
registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)",
|
|
@@ -141,6 +141,9 @@ module Samagotchi
|
|
|
141
141
|
:cuts,
|
|
142
142
|
:capped,
|
|
143
143
|
:model,
|
|
144
|
+
# The turn the user started by answering the step-limit question with
|
|
145
|
+
# Continue (:turn_started's +continue+); its record carries it.
|
|
146
|
+
:continue,
|
|
144
147
|
:id,
|
|
145
148
|
:started_at,
|
|
146
149
|
:started_monotonic,
|
|
@@ -539,6 +542,7 @@ module Samagotchi
|
|
|
539
542
|
cuts: 0,
|
|
540
543
|
capped: 0,
|
|
541
544
|
model: nil,
|
|
545
|
+
continue: event[:continue] ? true : false,
|
|
542
546
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
543
547
|
started_at: now.iso8601(3),
|
|
544
548
|
started_monotonic: monotonic_time,
|
|
@@ -575,6 +579,9 @@ module Samagotchi
|
|
|
575
579
|
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
576
580
|
record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
|
|
577
581
|
record[:failure] = failure if failure
|
|
582
|
+
# Every record says, so the report can tell a file with no Continue
|
|
583
|
+
# from one written before the mark.
|
|
584
|
+
record[:continue] = @turn.continue
|
|
578
585
|
record.merge!(turn_token_fields(@turn))
|
|
579
586
|
@turn_records << record
|
|
580
587
|
count_turn(record)
|
|
@@ -9,19 +9,22 @@ require_relative "session_metrics"
|
|
|
9
9
|
require_relative "recap_store"
|
|
10
10
|
require_relative "session_delete_command"
|
|
11
11
|
require_relative "session_archive_command"
|
|
12
|
+
require_relative "bridge_client"
|
|
13
|
+
require_relative "terminal_ui/formatting"
|
|
14
|
+
require_relative "terminal_ui/event_renderer"
|
|
12
15
|
require_relative "cli/command"
|
|
13
16
|
require_relative "cli/flags"
|
|
14
17
|
|
|
15
18
|
module Samagotchi
|
|
16
|
-
# `chi sessions`: list, stop, restart, archive/unarchive, delete, prune
|
|
17
|
-
# sessions from the shell (bin/chi dispatches here before
|
|
18
|
-
# the subcommands have their own flags). Not SessionCommands,
|
|
19
|
-
# slash commands for sessions (session_commands.rb).
|
|
19
|
+
# `chi sessions`: list, stop, restart, archive/unarchive, delete, prune,
|
|
20
|
+
# clean and stats sessions from the shell (bin/chi dispatches here before
|
|
21
|
+
# OptionParser; the subcommands have their own flags). Not SessionCommands,
|
|
22
|
+
# the REPL's slash commands for sessions (session_commands.rb).
|
|
20
23
|
class SessionsCommand
|
|
21
24
|
include CLI::Command
|
|
22
25
|
|
|
23
26
|
USAGE = <<~TEXT
|
|
24
|
-
Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean> [options]
|
|
27
|
+
Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean|stats> [options]
|
|
25
28
|
list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
|
|
26
29
|
[--live] [--cwd PATH] [--format text|json|tsv] [--archived]
|
|
27
30
|
--live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
|
|
@@ -34,12 +37,18 @@ module Samagotchi
|
|
|
34
37
|
delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
|
|
35
38
|
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
36
39
|
clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
40
|
+
stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
|
|
41
|
+
a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
|
|
37
42
|
Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
|
|
38
43
|
TEXT
|
|
39
44
|
|
|
40
45
|
STOP_USAGE = "Usage: chi sessions stop ID...\n"
|
|
41
46
|
RESTART_USAGE = "Usage: chi sessions restart ID...\n"
|
|
42
|
-
|
|
47
|
+
STATS_USAGE = "Usage: chi sessions stats ID [--format text|json]\n"
|
|
48
|
+
# How long `stats` waits for a live worker's GET stats before reading the
|
|
49
|
+
# saved analytics.json instead (a stuck worker must not hold a driver).
|
|
50
|
+
STATS_TIMEOUT = 3
|
|
51
|
+
SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean stats].freeze
|
|
43
52
|
|
|
44
53
|
# Each subcommand's flags ("--flag V" or "--flag=V" for a value): an
|
|
45
54
|
# unknown flag, a value flag with nothing after it or an argument where
|
|
@@ -53,6 +62,9 @@ module Samagotchi
|
|
|
53
62
|
end,
|
|
54
63
|
"stop" => CLI::Flags.new,
|
|
55
64
|
"restart" => CLI::Flags.new,
|
|
65
|
+
"stats" => CLI::Flags.new do |f|
|
|
66
|
+
f.value "--format"
|
|
67
|
+
end,
|
|
56
68
|
"prune" => CLI::Flags.new(args: false) do |f|
|
|
57
69
|
%w[--days --keep --keep-status].each { |name| f.value name }
|
|
58
70
|
f.switch "--dry-run"
|
|
@@ -98,6 +110,7 @@ module Samagotchi
|
|
|
98
110
|
when "list" then list
|
|
99
111
|
when "stop" then stop(parsed.args)
|
|
100
112
|
when "restart" then restart(parsed.args)
|
|
113
|
+
when "stats" then stats(parsed.args)
|
|
101
114
|
else prune(@sub)
|
|
102
115
|
end
|
|
103
116
|
end
|
|
@@ -105,7 +118,7 @@ module Samagotchi
|
|
|
105
118
|
private
|
|
106
119
|
|
|
107
120
|
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
|
|
108
|
-
def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE }.fetch(@sub, USAGE)
|
|
121
|
+
def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE, "stats" => STATS_USAGE }.fetch(@sub, USAGE)
|
|
109
122
|
|
|
110
123
|
# quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
|
|
111
124
|
def integers(options)
|
|
@@ -269,6 +282,70 @@ module Samagotchi
|
|
|
269
282
|
0
|
|
270
283
|
end
|
|
271
284
|
|
|
285
|
+
# One session's cost, tokens and progress, for a script or an agent that
|
|
286
|
+
# drives sessions (chi send --wait): a live worker's GET stats, else the
|
|
287
|
+
# snapshot its saved analytics.json rebuilds. Never starts a worker, and
|
|
288
|
+
# never sends the model anything.
|
|
289
|
+
def stats(ids)
|
|
290
|
+
return usage_error("give a session id") if ids.empty?
|
|
291
|
+
return usage_error("stats takes one session id") if ids.size > 1
|
|
292
|
+
|
|
293
|
+
format = @opts[:format]
|
|
294
|
+
unless format.nil? || %w[text json].include?(format)
|
|
295
|
+
return usage_error("unknown format #{format.inspect}: use --format text|json")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
id = begin
|
|
299
|
+
Samagotchi::Session.resolve_id(ids.first)
|
|
300
|
+
rescue Samagotchi::Session::AmbiguousId => e
|
|
301
|
+
@stdout.flush
|
|
302
|
+
@stderr.puts e.message
|
|
303
|
+
return 1
|
|
304
|
+
end
|
|
305
|
+
session = begin
|
|
306
|
+
Samagotchi::Session.load(id)
|
|
307
|
+
rescue ArgumentError => e
|
|
308
|
+
@stdout.flush
|
|
309
|
+
@stderr.puts e.message
|
|
310
|
+
return 1
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
metrics, live, note = stats_snapshot_for(session)
|
|
314
|
+
if format == "json"
|
|
315
|
+
@stdout.puts JSON.generate({ session_id: session.id, status: session.status, live: live, metrics: metrics })
|
|
316
|
+
else
|
|
317
|
+
@stdout.puts "#{session.id[0, 8]} #{session.status.ljust(7)} #{session.model_name}"
|
|
318
|
+
@stdout.puts TerminalUI::Formatting.format_session_metrics(metrics)
|
|
319
|
+
@stdout.puts note if note
|
|
320
|
+
end
|
|
321
|
+
0
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# [snapshot, live, note]: a live worker answers GET stats (no model call);
|
|
325
|
+
# without one — or one that does not answer in time — the snapshot comes
|
|
326
|
+
# from the session's saved analytics.json, rebuilt by SessionMetrics (the
|
|
327
|
+
# same collector a worker runs, loaded from disk). The note says when the
|
|
328
|
+
# worker did not answer. nil when the session has no analytics.json yet.
|
|
329
|
+
# Never starts a worker.
|
|
330
|
+
# @return [Array(Hash, nil, Boolean, String, nil)]
|
|
331
|
+
def stats_snapshot_for(session)
|
|
332
|
+
dir = Samagotchi::Session.session_dir(session.id)
|
|
333
|
+
if (port = Samagotchi::BridgeClient.sidecar_port(dir))
|
|
334
|
+
client = Samagotchi::BridgeClient.new(session_id: session.id, port: port, read_timeout: STATS_TIMEOUT)
|
|
335
|
+
body = client.get_json("stats")&.dig("metrics")
|
|
336
|
+
if body.is_a?(Hash)
|
|
337
|
+
return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
|
|
341
|
+
|
|
342
|
+
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.snapshot
|
|
343
|
+
note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
|
|
344
|
+
[snapshot, false, note]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def live_worker?(session_id) = Samagotchi::SessionManager.worker_live?(session_id)
|
|
348
|
+
|
|
272
349
|
def stop(ids)
|
|
273
350
|
return usage_error("give session ids") if ids.empty?
|
|
274
351
|
|
|
@@ -1226,7 +1226,7 @@ module Samagotchi
|
|
|
1226
1226
|
params: part[:params], status: status,
|
|
1227
1227
|
description: ToolView.description_title(part[:view] || {}) }
|
|
1228
1228
|
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1229
|
-
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1229
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
|
|
1230
1230
|
end
|
|
1231
1231
|
|
|
1232
1232
|
# The context notes that came since the last prompt (all of them in a
|
|
@@ -1320,7 +1320,7 @@ module Samagotchi
|
|
|
1320
1320
|
|
|
1321
1321
|
activity = EventRenderer.described(event) || {}
|
|
1322
1322
|
@screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
|
|
1323
|
-
description: activity[:description],
|
|
1323
|
+
description: activity[:description], output_truncated: event[:output_truncated],
|
|
1324
1324
|
images: event[:images], diff: event[:diff] }))
|
|
1325
1325
|
end
|
|
1326
1326
|
|
|
@@ -72,7 +72,8 @@ module Samagotchi
|
|
|
72
72
|
# A replayed row (a join) carries its duration (nil: unknown).
|
|
73
73
|
measured = tool_duration_ms(event)
|
|
74
74
|
render_streamed_tool_activity(self.class.described(event), duration_ms: event.fetch(:duration_ms, measured),
|
|
75
|
-
images: event[:images], diff: event[:diff]
|
|
75
|
+
images: event[:images], diff: event[:diff],
|
|
76
|
+
output_truncated: event[:output_truncated])
|
|
76
77
|
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
77
78
|
@view.generation_feedback_finished
|
|
78
79
|
when :pending_input_merged
|
|
@@ -211,13 +212,14 @@ module Samagotchi
|
|
|
211
212
|
|
|
212
213
|
private
|
|
213
214
|
|
|
214
|
-
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
|
|
215
|
+
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil, output_truncated: nil)
|
|
215
216
|
return if activity.nil?
|
|
216
217
|
|
|
217
218
|
@streamed_tool_activity[tool_activity_key(activity)] += 1
|
|
218
219
|
line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
|
|
219
220
|
line += @view.format_tool_image_suffix(images) if images&.any?
|
|
220
221
|
line += @view.format_tool_diff_suffix(diff) if diff
|
|
222
|
+
line += @view.format_tool_cut_suffix(output_truncated) if output_truncated
|
|
221
223
|
@view.print_line(line)
|
|
222
224
|
end
|
|
223
225
|
|
|
@@ -14,6 +14,14 @@ module Samagotchi
|
|
|
14
14
|
# `tool>` line, elapsed durations. Pure apart from reading whether
|
|
15
15
|
# $stdout is a colour terminal.
|
|
16
16
|
module Formatting
|
|
17
|
+
# The /stats text for a metrics snapshot, for the REPL and attached
|
|
18
|
+
# views (an instance includes the module) and for `chi sessions stats`
|
|
19
|
+
# (no UI: the same text from the CLI).
|
|
20
|
+
# @return [String] "(no metrics yet)" without a snapshot
|
|
21
|
+
def self.format_session_metrics(snapshot)
|
|
22
|
+
(@formatter ||= Object.new.extend(Formatting)).send(:format_session_metrics, snapshot)
|
|
23
|
+
end
|
|
24
|
+
|
|
17
25
|
def format_tool_activity_line(activity, duration_ms: nil)
|
|
18
26
|
status = activity[:status].to_s
|
|
19
27
|
elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
|
|
@@ -138,6 +146,12 @@ module Samagotchi
|
|
|
138
146
|
" #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
|
|
139
147
|
end
|
|
140
148
|
|
|
149
|
+
# A row whose output the runner cut (max_tool_output_chars): " [cut]",
|
|
150
|
+
# dim, beside the diff one.
|
|
151
|
+
def format_tool_cut_suffix(output_truncated)
|
|
152
|
+
output_truncated ? " #{paint("[cut]", 90)}" : ""
|
|
153
|
+
end
|
|
154
|
+
|
|
141
155
|
# Prompt labels by the sender's client_id: a whole id, or a prefix
|
|
142
156
|
# (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
|
|
143
157
|
# spec/shared/labels_matrix.json).
|
|
@@ -212,7 +226,7 @@ module Samagotchi
|
|
|
212
226
|
def snapshot_tool_line(part)
|
|
213
227
|
status = part[:status].to_s
|
|
214
228
|
"#{paint("tool>", 36)} #{part[:tool]}#{tool_params_suffix(part)}: #{paint(status, status_color(status))}" \
|
|
215
|
-
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
229
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
|
|
216
230
|
end
|
|
217
231
|
|
|
218
232
|
# A card (Engine#show_card) as a framed block: the title and its
|
|
@@ -482,7 +496,8 @@ module Samagotchi
|
|
|
482
496
|
end
|
|
483
497
|
|
|
484
498
|
# Render the analytics snapshot as a compact, user-facing report. Raw event
|
|
485
|
-
# logs (debug-only) are intentionally excluded; this surface is for the REPL
|
|
499
|
+
# logs (debug-only) are intentionally excluded; this surface is for the REPL
|
|
500
|
+
# and `chi sessions stats` (the module function above).
|
|
486
501
|
def format_session_metrics(snapshot)
|
|
487
502
|
return "(no metrics yet)" unless snapshot.is_a?(Hash)
|
|
488
503
|
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
} from "./timing.js";
|
|
63
63
|
import { createLiveTurn } from "./live_turn.js";
|
|
64
64
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
65
|
-
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
65
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
66
66
|
|
|
67
67
|
const $ = (s) => document.querySelector(s);
|
|
68
68
|
const topStripEl = $("#topStrip");
|
|
@@ -2274,6 +2274,10 @@ const streamHandlers = {
|
|
|
2274
2274
|
queued.remove();
|
|
2275
2275
|
addCommandBubble(view);
|
|
2276
2276
|
} else if (queued) drawCommandBubble(queued, view);
|
|
2277
|
+
// A command that changed the conversation (!cmd): the redrawn history
|
|
2278
|
+
// ends with its command bubble already (the saved "!(…)\n<output>"
|
|
2279
|
+
// message renders as one) — no second bubble, or the output shows twice.
|
|
2280
|
+
else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
|
|
2277
2281
|
else addCommandBubble(view);
|
|
2278
2282
|
},
|
|
2279
2283
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
@@ -4,15 +4,24 @@
|
|
|
4
4
|
// on (a provider retry, plugins' setup) and how many turns this page saw
|
|
5
5
|
// end. The DOM, the clock and the interval come in, so `node --test` drives
|
|
6
6
|
// it with a fake element.
|
|
7
|
-
import { elapsedSince, turnTimingText } from "./timing.js";
|
|
7
|
+
import { elapsedSince, liveDurationText, turnTimingText } from "./timing.js";
|
|
8
8
|
|
|
9
9
|
// The live line's text, or null when the turn's start is unknown. +inStage+:
|
|
10
10
|
// the stage's status row already says the turn runs, so the line drops its
|
|
11
|
-
// own "running" there.
|
|
11
|
+
// own "running" there. The duration is the live one (never "0ms": under a
|
|
12
|
+
// second it shows tenths).
|
|
12
13
|
export function liveLineText({ number, startedAt, note = "", inStage = false }, now = Date.now()) {
|
|
13
14
|
const duration = elapsedSince(startedAt, now);
|
|
14
15
|
if (duration === null) return null;
|
|
15
|
-
|
|
16
|
+
const turn = Number.isInteger(number) && number > 0 ? `turn ${number}` : "turn";
|
|
17
|
+
return `${turn}${!inStage ? " running" : ""} · ${liveDurationText(duration)}${note ? ` · ${note}` : ""}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The live ticker's interval at +elapsedMs+ into the turn: 200 ms during the
|
|
21
|
+
// first ten seconds (the live text counts up from the first second), then
|
|
22
|
+
// the one-second tick.
|
|
23
|
+
export function liveTickMs(elapsedMs) {
|
|
24
|
+
return Number(elapsedMs) < 10000 ? 200 : 1000;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
// The record a turn-end read finishes its line with: the one with the
|
|
@@ -52,18 +61,36 @@ export function createLiveTiming({
|
|
|
52
61
|
// the next one, so a turn queued behind another reads its own number while
|
|
53
62
|
// the other's re-read is still out.
|
|
54
63
|
let ended = 0;
|
|
64
|
+
// The ticker's current interval, so a tick that crossed the 10 s boundary
|
|
65
|
+
// re-arms it at the slower one.
|
|
66
|
+
let tickerMs = null;
|
|
55
67
|
|
|
56
68
|
function stopTicker() {
|
|
57
69
|
if (ticker !== null) {
|
|
58
70
|
cancel(ticker);
|
|
59
71
|
ticker = null;
|
|
72
|
+
tickerMs = null;
|
|
60
73
|
}
|
|
61
74
|
}
|
|
62
75
|
|
|
76
|
+
function armTicker(ms) {
|
|
77
|
+
stopTicker();
|
|
78
|
+
ticker = every(tick, ms);
|
|
79
|
+
tickerMs = ms;
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
function tick() {
|
|
64
|
-
const
|
|
83
|
+
const startedAt = timing().activeTurn?.started_at;
|
|
84
|
+
const text = liveLineText({ number, startedAt, note, inStage }, now());
|
|
65
85
|
if (el && text !== null) el.textContent = text;
|
|
66
86
|
onTick();
|
|
87
|
+
// The interval follows the elapsed time: 200 ms while the line counts
|
|
88
|
+
// up from the first second, one a second once it is on whole seconds.
|
|
89
|
+
if (ticker !== null) {
|
|
90
|
+
const elapsed = elapsedSince(startedAt, now());
|
|
91
|
+
const ms = elapsed === null ? tickerMs : liveTickMs(elapsed);
|
|
92
|
+
if (ms !== tickerMs) armTicker(ms);
|
|
93
|
+
}
|
|
67
94
|
}
|
|
68
95
|
|
|
69
96
|
return {
|
|
@@ -87,7 +114,7 @@ export function createLiveTiming({
|
|
|
87
114
|
el.className = "turn-timing live";
|
|
88
115
|
inStage = !!place(el);
|
|
89
116
|
tick();
|
|
90
|
-
|
|
117
|
+
armTicker(liveTickMs(0));
|
|
91
118
|
},
|
|
92
119
|
|
|
93
120
|
tick,
|
|
@@ -108,6 +108,21 @@ function withTask(slot, row) {
|
|
|
108
108
|
return row.tool === "task_wait" && row.view?.task_id ? { ...slot, taskId: row.view.task_id, key: row.key } : slot;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// A trail item's text and its hover: the text the (CSS-truncated) item shows,
|
|
112
|
+
// and the full text it hovers. A call hovers its full command when it has
|
|
113
|
+
// one, else its whole "<mark> <name> <title>" text; a flash (a hook notice)
|
|
114
|
+
// hovers its whole "<mark> <text>" line.
|
|
115
|
+
export function trailItemText(item) {
|
|
116
|
+
const text = `${trailMark(item.status)} ${item.name}${item.title ? ` ${item.title}` : ""}`;
|
|
117
|
+
return { text, hover: item.command || text };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A trail flash's text and hover: the whole "<mark> <text>" line.
|
|
121
|
+
export function trailFlashText(flash, mark) {
|
|
122
|
+
const text = `${mark} ${flash.text}`;
|
|
123
|
+
return { text, hover: text };
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
// While the first step has no thinking and no tool rows the turn is plain:
|
|
112
127
|
// its text streams in the stage's answer slot, as the turn view's
|
|
113
128
|
// chrome-less block streams like a bubble.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// reload. Slots are patched in place, never rebuilt from HTML: a selection
|
|
12
12
|
// and annotate survive. The rules are pure in stage_model.js.
|
|
13
13
|
import { createTurnView } from "./turn_view.js";
|
|
14
|
-
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor,
|
|
14
|
+
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
|
|
15
15
|
import { formatDuration } from "./timing.js";
|
|
16
16
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
17
17
|
|
|
@@ -265,12 +265,18 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
265
265
|
if (shown.trail === key) return;
|
|
266
266
|
shown.trail = key;
|
|
267
267
|
trail.replaceChildren();
|
|
268
|
-
if (flashing)
|
|
268
|
+
if (flashing) {
|
|
269
|
+
const flash = trailFlashText(flashing, FLASH_MARKS[flashing.kind]);
|
|
270
|
+
const el = node("span", `ts-trail-item ${flashing.kind}`, trail);
|
|
271
|
+
el.textContent = flash.text;
|
|
272
|
+
el.title = flash.hover;
|
|
273
|
+
}
|
|
269
274
|
for (const item of items) {
|
|
270
275
|
const tone = trailTone(item.status);
|
|
271
276
|
const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
|
|
272
|
-
|
|
273
|
-
|
|
277
|
+
const { text, hover } = trailItemText(item);
|
|
278
|
+
el.textContent = text;
|
|
279
|
+
el.title = hover;
|
|
274
280
|
}
|
|
275
281
|
if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
|
|
276
282
|
}
|
|
@@ -20,6 +20,17 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
|
|
|
20
20
|
return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// The LIVE line's duration: never milliseconds — under a second it shows
|
|
24
|
+
// tenths ("0.0s", "0.2s"), then what formatDuration prints from 1 s on
|
|
25
|
+
// (its 0.1 s precision holds to 10 s). A finished turn's line keeps
|
|
26
|
+
// formatDuration exactly as it is.
|
|
27
|
+
export function liveDurationText(durationMs) {
|
|
28
|
+
const ms = Number(durationMs);
|
|
29
|
+
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
30
|
+
if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
31
|
+
return formatDuration(ms);
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
// A cancel's reason as the terminal UIs name it too (Formatting::
|
|
24
35
|
// CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
|
|
25
36
|
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
|
|
@@ -370,6 +370,17 @@ export function commandView(event, myId) {
|
|
|
370
370
|
};
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
// Whether a command_ran that re-read the conversation (`resync`) needs its
|
|
374
|
+
// own bubble: the redrawn history ends with a command bubble for its line
|
|
375
|
+
// (a `!cmd` appends its "!(<command>)\n<output>" message, which renders as
|
|
376
|
+
// one), so a second bubble would show the output twice. +historyBubbles+:
|
|
377
|
+
// the `.bubble.command` elements in document order; +line+: the event's
|
|
378
|
+
// line. Only the last one counts: it is the redrawn history's end.
|
|
379
|
+
export function historyHasCommandBubble(historyBubbles, line) {
|
|
380
|
+
const last = historyBubbles[historyBubbles.length - 1];
|
|
381
|
+
return !!last && last.querySelector(":scope > .command-line")?.textContent === line;
|
|
382
|
+
}
|
|
383
|
+
|
|
373
384
|
// What a prompt_restored (the worker rolled a failed turn back and handed its
|
|
374
385
|
// prompt back) does in this tab: `refill` is the text for an empty composer,
|
|
375
386
|
// only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
|
|
@@ -99,6 +99,27 @@ export function stateBadgeHtml(state) {
|
|
|
99
99
|
return BADGES[state] ? `<span class="state-badge">${escapeHtml(BADGES[state])}</span>` : "";
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
|
|
103
|
+
// for the next turn): its command line and its output, or null when the
|
|
104
|
+
// content is any other message. The command sits on the first line inside
|
|
105
|
+
// `!(` `)`; the output is everything after that first newline. A message
|
|
106
|
+
// that merely starts with "!" or "!(" without the newline shape is a plain
|
|
107
|
+
// message.
|
|
108
|
+
export function shellCommandMessage(content) {
|
|
109
|
+
const text = String(content ?? "");
|
|
110
|
+
const match = text.match(/^!\((.*)\)\n([\s\S]*)$/);
|
|
111
|
+
if (!match) return null;
|
|
112
|
+
return { line: `!${match[1]}`, text: match[2] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A `!cmd`'s history bubble: the same markup the live page draws for a
|
|
116
|
+
// command (app.js's drawCommandBubble): the line, the output in a
|
|
117
|
+
// pre.command-output.
|
|
118
|
+
export function commandBubbleHtml({ line, text }) {
|
|
119
|
+
const body = text ? `<pre class="command-output">${escapeHtml(text)}</pre>` : "";
|
|
120
|
+
return `<div class="bubble command"><div class="command-line">${escapeHtml(line)}</div>${body}</div>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
102
123
|
// A user's line: who sent it (+label+, a delegate report's or another
|
|
103
124
|
// client's), its body (`>` lines as quotes) and +thumbs+ (its images'
|
|
104
125
|
// markup), its state's badge. +message+: { content, display } (the copy
|
|
@@ -15,7 +15,7 @@ import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourc
|
|
|
15
15
|
import { thumbsHtml } from "./images.js";
|
|
16
16
|
import { shouldFollowScroll } from "./scroll.js";
|
|
17
17
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
18
|
-
import { emptyAnswerHtml, htmlElement, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
|
|
18
|
+
import { commandBubbleHtml, emptyAnswerHtml, htmlElement, shellCommandMessage, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
|
|
19
19
|
import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
20
20
|
import { createSentenceFeed } from "./sentences.js";
|
|
21
21
|
import { createTicker } from "./thinking_ticker.js";
|
|
@@ -755,8 +755,14 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
755
755
|
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
756
756
|
return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
|
|
757
757
|
};
|
|
758
|
-
// A user's line, labelled when chi brought it in (a delegate report).
|
|
759
|
-
|
|
758
|
+
// A user's line, labelled when chi brought it in (a delegate report). A
|
|
759
|
+
// `!cmd`'s saved message (the worker's "!(<command>)\n<output>") renders
|
|
760
|
+
// as its command bubble, as live does — not as a user bubble.
|
|
761
|
+
const userHtml = (m, opts = {}) => {
|
|
762
|
+
const shell = shellCommandMessage(m?.content);
|
|
763
|
+
if (shell) return commandBubbleHtml(shell);
|
|
764
|
+
return userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
|
|
765
|
+
};
|
|
760
766
|
// The first text after a delegate report merged mid-turn, unless it is
|
|
761
767
|
// the turn's answer (shown anyway).
|
|
762
768
|
const reportReplyHtml = (i, group) => {
|
|
@@ -307,12 +307,20 @@ module Samagotchi
|
|
|
307
307
|
emit("session", session: summary)
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
-
# One HostRegistry
|
|
310
|
+
# One HostRegistry kept across scans: every session's ctx_pct may
|
|
311
311
|
# need its llm_context budget resolved, and the registry's config read
|
|
312
312
|
# is what would otherwise repeat per scan and per session. Built on
|
|
313
|
-
# the first summary that needs it.
|
|
313
|
+
# the first summary that needs it, and built again when config.yml's
|
|
314
|
+
# hosts: changed (compared as read, like App#host_registry: the YAML
|
|
315
|
+
# read is mtime-cached), so a host added or a window changed while
|
|
316
|
+
# chi web runs is picked up on the next refresh of a card.
|
|
314
317
|
def hub_registry
|
|
315
|
-
|
|
318
|
+
hosts = ConfigFile.hosts_config
|
|
319
|
+
if @hub_registry.nil? || hosts != @hub_registry_hosts
|
|
320
|
+
@hub_registry = HostRegistry.new(hosts_config: hosts)
|
|
321
|
+
@hub_registry_hosts = hosts
|
|
322
|
+
end
|
|
323
|
+
@hub_registry
|
|
316
324
|
end
|
|
317
325
|
|
|
318
326
|
def drop(id)
|