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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca248891309ffde628ce6c03c2bbd35fa946f7fa669978c30d1478a0f236a920
4
- data.tar.gz: 50e85c20fa0882c5caeeaf7a2c141dd46a443c5f725d60120e194ada9152871c
3
+ metadata.gz: a34348a50eeb261495c985e51195fa87b9f04121e56852083b014229bf05323a
4
+ data.tar.gz: 4bc36375cf4eb7bfba24212c080b86759026c29e17bae2adae653f0d2f7e88f2
5
5
  SHA512:
6
- metadata.gz: e6ce68e65e80c48433bdb55706aaa19790ab6a140f81550f63420c1f4574d1e423a2dd356a01fda3534234331eacfb9a03151e3fa83b602d027d039ad44c76ab
7
- data.tar.gz: a476e538ff5913c82625a18c8e23b822bc402f7ed206724debe65c95863a1ab7ab2606823befae4c44a0e265b3a8a45eafcf08c6b2215a27dab4bd6c03ac98ec
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.49.0...HEAD
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` that had no prompt of their own), steers (lines that reached a
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.6
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:ab71b6f2aee6ad15bbe494cf0e8943046ee94dbf5d56149bd507725e8a9790fe
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? || @watch_done
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.49.0
3
+ version: 0.50.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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
- def started(iteration)
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 and clean
17
- # sessions from the shell (bin/chi dispatches here before OptionParser;
18
- # the subcommands have their own flags). Not SessionCommands, the REPL's
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
- SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean].freeze
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
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.49.0"
4
+ VERSION = "0.50.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- return turnTimingText(number, duration, { running: !inStage }) + (note ? ` · ${note}` : "");
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 text = liveLineText({ number, startedAt: timing().activeTurn?.started_at, note, inStage }, now());
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
- ticker = every(tick, 1000);
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, trailMark, trailTone } from "./stage_model.js";
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) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
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
- el.textContent = `${trailMark(item.status)} ${item.name} ${item.title}`.trim();
273
- if (item.command) el.title = item.command;
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
- const userHtml = (m, opts = {}) => userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
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 for the hub's lifetime: every session's ctx_pct may
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
- @hub_registry ||= HostRegistry.new
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.49.0
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov