samagotchi 0.70.0 → 0.71.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: d5031a069a844109b4d11d2aa6c43e8471da137decd1231a45a6e5ac6a57176c
4
- data.tar.gz: 9f4c0d1696b567d42bef5594288a3068781c0e38b52cee478cb2d551fcfe2d6e
3
+ metadata.gz: b92ddae3d906d7ba8192ddb77ac75b8d3f12e5d34ffc146f45ca18971a3df9d7
4
+ data.tar.gz: 668e0a999ef5cf276e8b40953a968301c84930399fb2e2ace30e34e18332641e
5
5
  SHA512:
6
- metadata.gz: 3f4718ac2eb9e287fd6a023b619fc6b3ad78f13e55b3edc4c5715ba164e9c5aea0afe08154fbda59d07f83a6120aa96cd77d9e2d36b10738a27d8246c33e3fc7
7
- data.tar.gz: f33e3a9813f3fdc914489d69261c975b2c2699a642896d757374dac2ff281e9bbce776cacd5f4b815bd77a6fa1f9bc5c4400413aaeb90cf95dd04be2f50b49b3
6
+ metadata.gz: 9ebddc5482d48c2e8b8d637b967ea87c939c267a79db1839dbfd7286459ccb6a303b75e2b2307a6456bc5aafc74228f1a49b912e18f4b5e31eacb1368589cf2c
7
+ data.tar.gz: ef0521a3cfa0af34d6469f8b5a77ed0fd8a34bd5f300e08a8a6ef5b1f0878dfc643db2e56b08b0ee0e9f414ed5ed5c5dc875ca1a1410176e2c0e8205dd31b256
data/CHANGELOG.md CHANGED
@@ -8,6 +8,43 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.71.0] - 2026-10-15
12
+
13
+ ### Changed
14
+
15
+ - Web: an `execute` row, and a `task_wait` row showing its log's last lines, now shows the end of its output (a
16
+ test run's summary, a failing exit line) instead of the start, without the success `exit: 0` line or a lone
17
+ `stdout:` label; the whole output stays on hover.
18
+ - `task_wait` on a task that finished now returns the last lines of its log too (`output_tail:`, `tail_lines` of
19
+ them, 10 by default), as a timed-out wait does: a test run's summary or an error is there without reading the
20
+ log; its web row shows them.
21
+ - Web: a page opened mid-turn (or reconnecting) gets each tool's output cut as a reload does (2000 characters),
22
+ so a joined row matches the reloaded one and the snapshot is smaller.
23
+ - Web: a tool row whose call the guardrails corrected shows its output without the `ran as: …` line inline (it
24
+ stays on hover).
25
+
26
+ ### Fixed
27
+
28
+ - A command a signal ended (a segfault, an OOM kill: its result ends `exit: ` with no number) is read as finished:
29
+ a long output's cut keeps its exit line apart and tells the model not to rerun it (the saved file is the whole
30
+ output), and its web row shows the output's end as other failed commands' do.
31
+ - `chi sessions`: a `--days`, `--keep` or `--limit` that isn't a whole number (`--days=abc`) is now a usage error
32
+ (exit 2) instead of being read as 0, which turned `prune`'s age limit off; the usage now lists `clean --all`.
33
+ - `full` with `--no-tool call_tool` (or `find_tools`) and no MCP server no longer adds a "Not loaded in this
34
+ session" line to the system prompt nor a web toolkit chip: there call_tool reaches nothing, so nothing is left
35
+ out.
36
+ - A `toolkit.kits` entry with a key other than `base:`, `add:` and `remove:` (a typo, a `tools:` list) now warns
37
+ (once, as the toolkit's other config warnings) instead of being ignored silently; the kit still loads.
38
+ - Attached terminal: a session's reloaded tool rows read each result as the live row did: a command handed over
39
+ as a task is no longer shown failed because its output so far had `exit: 1`, and a failed `execute`
40
+ (`Error: …`) is no longer shown done.
41
+ - A `task_wait` row is marked stopped only by the wait's own status, not by a `status: stopped` line the task's
42
+ log printed.
43
+ - A `task_wait` whose task failed (exited non-zero, or without an exit code) is now marked failed in the web and
44
+ terminal rows and counted in the turn's "(N failed)", as a failing `execute` is.
45
+
46
+ Update with `chi update` (the system bundle 0.71.0), then restart `chi web`.
47
+
11
48
  ## [0.70.0] - 2026-10-15
12
49
 
13
50
  ### Added
@@ -3029,7 +3066,8 @@ and long-lived sessions.
3029
3066
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3030
3067
  hotkey panel that send selected text or the clipboard to your sessions.
3031
3068
 
3032
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.70.0...HEAD
3069
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.71.0...HEAD
3070
+ [0.71.0]: https://github.com/dm1try/samagotchi/compare/v0.70.0...v0.71.0
3033
3071
  [0.70.0]: https://github.com/dm1try/samagotchi/compare/v0.69.0...v0.70.0
3034
3072
  [0.69.0]: https://github.com/dm1try/samagotchi/compare/v0.68.0...v0.69.0
3035
3073
  [0.68.0]: https://github.com/dm1try/samagotchi/compare/v0.67.0...v0.68.0
data/docs/architecture.md CHANGED
@@ -337,7 +337,7 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
337
337
  alone (no session file parsed, no `analytics.json`); `timing` carries the worker's recent records
338
338
  and `turn_count` (finished turns). The page merges the records by id (`timing.js mergeTiming`) and
339
339
  finishes the ended turn's own line by its `turn_id` (sent as `&turn_id=`, which also picks that
340
- turn's answer). A plain `?tail=1` (a tab opened before this) gets the whole timing.
340
+ turn's answer). A tail read's timing is always the newest turn's; `recent=1` is accepted and not needed.
341
341
  The page binds an answer's `display_refs` to its bubble (`answer_refs.js`: on a reload's render and on this
342
342
  read's re-render) and pairs them with the bubble's links and text before the `path:line` scan.
343
343
  A plugin tool's row refs (`refs`, `RowRefs`) ride its tool events and, on a reload, its part (saved as
@@ -349,7 +349,7 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
349
349
 
350
350
  A worker without `/tail` (an older chi; `404 not_found`) is read through `/snapshot` in the same
351
351
  shape; any other failure (no worker, a 500, a timeout) reads the disk, trimmed to the newest
352
- turn's records with `recent=1`.
352
+ turn's records.
353
353
 
354
354
  Every route goes through `Bridge#dispatch`: an id other than the bridge's own session is
355
355
  `404 unknown_session`, and a handler that raises answers `500 bridge_error` (logged as
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|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
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`, a `--days`, `--keep` or `--limit` that isn't a whole number) 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]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); 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))
@@ -766,7 +766,9 @@ tool rows), the earlier ones stack above it collapsed to one line each
766
766
  (their narration's first line, else their first call's title such as
767
767
  `edit lib/a.rb`, and a call count), expandable for inspection. A tool row
768
768
  says what the call did: a file's path relative to the session's folder, a
769
- command without its leading `cd … &&` (the full parameters on hover). The live thinking is one line: the
769
+ command without its leading `cd … &&` (the full parameters on hover). Under it a done row shows its
770
+ output's first 300 characters; an `execute` and a `task_wait` with its log's tail show the last ones (a test
771
+ run's summary, a failing exit line), without the success `exit: 0` line; a gate-corrected call's `ran as:` line only on hover; all of it on hover. The live thinking is one line: the
770
772
  newest complete sentence, changing at most once per 1.5 s. Click it for the
771
773
  full text; a peek is per step (the next step's thinking starts closed
772
774
  again). When a step ends its thinking closes to a plain `thinking` line
@@ -1340,8 +1340,8 @@ toolkit:
1340
1340
  | `coordinator` | `lean` plus `delegate`, `delegate_result`, `list_sessions` (declared by the coordinator bundle) |
1341
1341
 
1342
1342
  - **Plugin tools** are declared in every toolkit unless its `remove:` names them, and so is `forget_outputs` under
1343
- the `forget` layer. A `kits:` entry takes `base:` (default `full`), `add:` and `remove:`; it can't reuse a
1344
- built-in's name. A bundle declares its own with `chi.toolkit` ([plugins](plugins.md#chitoolkitname-base-add--remove-)).
1343
+ the `forget` layer. A `kits:` entry takes `base:` (default `full`), `add:` and `remove:` (any other key, a typo
1344
+ or a `tools:` list, is ignored with a warning); it can't reuse a built-in's name. A bundle declares its own with `chi.toolkit` ([plugins](plugins.md#chitoolkitname-base-add--remove-)).
1345
1345
  - **Saved at creation, never re-resolved from config.** A session stores its toolkit as named
1346
1346
  (`"toolkit": {"name": "lean", "add": [], "remove": []}`) when it is created: `toolkit.default`, `toolkit.child`
1347
1347
  for a delegate child, or `chi --toolkit NAME` / `--tool` / `--no-tool` ([cli](cli.md)). Changing `toolkit.default`
@@ -1355,7 +1355,7 @@ toolkit:
1355
1355
  call_tool can't reach it (`child` removes `memory_write`, `delegate` and `delegate_result`, so a child on it
1356
1356
  starts no children). To keep a built-in away from the model, name it under `remove:`. `find_tools` and `call_tool`
1357
1357
  are declared when a toolkit has a reachable built-in (or an MCP server is configured); `full` with no MCP server
1358
- declares neither.
1358
+ declares neither, and removing them there (`--no-tool call_tool`) leaves nothing out: no prompt line, no chip.
1359
1359
  - **The prompt line.** One line after the base prompt names them: `Not declared in this session (toolkit lean): ….
1360
1360
  Search them with find_tools; call one with call_tool.`, then `Removed (toolkit child): memory_write, …. Only the
1361
1361
  user can load one (/toolkit add <name>).` With `find_tools` removed, the first sentence ends `Call one with
@@ -11,7 +11,7 @@ Samagotchi supports long-running commands in the background through five task to
11
11
  Recommended workflow:
12
12
 
13
13
  1. Create a task with `task_create`.
14
- 2. Use `task_wait` once. On timeout it returns the last 10 log lines, avoiding a separate read just to see progress.
14
+ 2. Use `task_wait` once. Every wait returns the log's last `tail_lines` lines (10 by default): a finished task's (its summary, its error), a timed-out or pattern-matched one's progress. So there is no separate read just to see them.
15
15
  3. For commands with a reliable completion marker, pass `done_pattern` to return when the recent log tail matches it.
16
16
  4. Use `task_get` or `task_list` for nonblocking status checks, and `task_stop` if needed.
17
17
 
@@ -26,7 +26,7 @@ Behavior:
26
26
  - The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. A command that sets its own EXIT trap replaces it: `execute` still reports the shell's status, and a handed-over one gets its code from chi's waiter while that chi runs; a `task_create` task ends failed with no exit code.
27
27
  - A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
28
28
  - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path`. `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
29
- - `task_wait` and `task_get` show `stderr_path` when the record has one; where `task_wait` returns an output tail, a `stderr_tail:` of at most 10 lines follows it when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
29
+ - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
30
30
  - Which tasks are a session's own (a cancelled turn's note, the turn-end note, a worker's idle exit, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
32
32
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
data/docs/sessions.md CHANGED
@@ -45,7 +45,7 @@ chi sessions archive ID... # hide from every list, ke
45
45
  chi sessions list --archived # archived sessions too, marked [archived]
46
46
  chi sessions delete [--force] ID... # for good; --force stops a live worker first
47
47
  chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
48
- chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
48
+ chi sessions clean [--dry-run] [--days N] [--all] # test sessions: all, or older than N days; --all: every kind, as prune
49
49
  chi sessions stats ID [--format text|json] # cost, tokens and progress, without a model turn
50
50
  ```
51
51
 
data/docs/testing.md CHANGED
@@ -56,8 +56,8 @@ script/affected-specs A..B # another range (A...B: from their merge-b
56
56
  A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
57
57
  files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
58
58
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
59
- file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`), then the other name
60
- matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
59
+ file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
60
+ words the REPL prints, pick it by a rule), then the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
61
61
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
62
62
  `spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
63
63
  The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" and a
@@ -18,8 +18,11 @@ module Samagotchi
18
18
  # its terminal event, the same event that brings its messages into the
19
19
  # session, so a snapshot shows a turn in exactly one of the two.
20
20
  class TurnAccumulator
21
+ # @param max_output_chars [Integer] a tool part's output past this keeps
22
+ # its head and tail: a reload's cap, so a joined row is the row a
23
+ # reload draws
21
24
  # @param clock [#call] monotonic seconds: a tool call's duration
22
- def initialize(max_output_chars: KernelLoop::DEFAULT_MAX_TOOL_OUTPUT_CHARS,
25
+ def initialize(max_output_chars: ToolOutputCut::WEB_PREVIEW_CHARS,
23
26
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
24
27
  @max_output_chars = max_output_chars
25
28
  @clock = clock
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.70.0
3
+ version: 0.71.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:
@@ -37,7 +37,8 @@ module Samagotchi
37
37
  archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
38
38
  delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
39
39
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
40
- 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
+ clean [--dry-run] [--days N] [--all] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
41
+ --all: every session, not only test and scratch ones (by the age and count limits, as prune)
41
42
  stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
42
43
  a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
43
44
  Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
@@ -107,6 +108,8 @@ module Samagotchi
107
108
  return parsed if parsed.is_a?(Integer)
108
109
 
109
110
  @opts = integers(parsed.options)
111
+ return usage_error(@opts) if @opts.is_a?(String)
112
+
110
113
  case @sub
111
114
  when "list" then list
112
115
  when "stop" then stop(parsed.args)
@@ -121,9 +124,15 @@ module Samagotchi
121
124
  def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
122
125
  def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE, "stats" => STATS_USAGE }.fetch(@sub, USAGE)
123
126
 
124
- # quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
127
+ # The options with --days, --keep and --limit as integers, or the usage
128
+ # error's message for one that isn't a whole number (--days=abc must not
129
+ # turn the age limit off).
130
+ # @return [Hash, String]
125
131
  def integers(options)
126
- options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? value.to_i : value] }
132
+ bad = options.find { |key, value| INTEGER_OPTIONS.include?(key) && !value.to_s.match?(/\A-?\d+\z/) }
133
+ return "--#{bad[0]} takes a whole number, got #{bad[1].to_s.inspect}" if bad
134
+
135
+ options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? Integer(value, 10) : value] }
127
136
  end
128
137
 
129
138
  # The flags a list row carries: a chi scratch session, a test run, an
@@ -1328,7 +1328,12 @@ module Samagotchi
1328
1328
  def join_tool_line(part, duration_ms: nil)
1329
1329
  output = part[:output]
1330
1330
  command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1331
- status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool], command: command)
1331
+ status = if output.nil?
1332
+ "no result"
1333
+ else
1334
+ ToolActivity.tool_activity_status(ToolActivity.saved_result_text(output), part[:tool],
1335
+ command: command)
1336
+ end
1332
1337
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1333
1338
  params: part[:params], status: status,
1334
1339
  description: ToolView.description_title(part[:view] || {}) }
@@ -35,6 +35,14 @@ module Samagotchi
35
35
 
36
36
  module_function
37
37
 
38
+ # A saved tool output's leading "ran as: …" line (ToolRunner#changed_call_line) and "[tool]" lead (the
39
+ # shape of ToolResponse::LEAD), which the live status never sees: it reads the tool's own result.
40
+ SAVED_LEAD = /\A(?:ran as: [^\n]*\n)?\[[^\]\s]+\](?:\n| |\z)/
41
+
42
+ # A saved output (what the model got) as the tool's own text, for tool_activity_status.
43
+ # Without a "[tool]" lead (an older or hand-made output) it stays as it is.
44
+ def saved_result_text(output) = output.to_s.sub(SAVED_LEAD, "")
45
+
38
46
  # @param registry [Tools::Registry, nil] the session's tools
39
47
  # @param cwd [String, nil] what a file tool's title is relative to (the
40
48
  # worker runs in its session's working directory)
@@ -211,12 +219,16 @@ module Samagotchi
211
219
  # exited non-zero (its last "exit: N" line; no number: killed by a
212
220
  # signal), so the turn tally's "(N failed)" counts it; but not a grep
213
221
  # that found nothing (no_match?, given the +command+): that stays "ok".
222
+ # Also for a task_wait whose task failed (exited non-zero or without an
223
+ # exit code): its header's status, never a line of the log's tail.
214
224
  # "stopped" for a
215
225
  # task_wait the user's Stop ended (the task itself runs on), for one
216
226
  # whose task was stopped (the stop-task button, or the model's task_stop),
217
227
  # and for a command the user's Stop (or a signal to chi, a session stop)
218
228
  # killed or kept from starting: a cancel, not a failure (its text still
219
229
  # starts "Error:" for the model).
230
+ # A task_wait's own outcome is read from its header alone: the log's tail
231
+ # can print any line, "status: stopped" included.
220
232
  def tool_activity_status(result, tool_name = nil, command: nil)
221
233
  text = result.to_s
222
234
  return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, *Tools::Execute::NOT_RUN.values].include?(text)
@@ -230,7 +242,13 @@ module Samagotchi
230
242
  if tool_name == Tools::Execute::NAME && execute_failed?(text)
231
243
  return no_match?(text, command) ? "ok" : "error"
232
244
  end
233
- return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
245
+
246
+ if tool_name == Tools::TaskWait::NAME
247
+ # The header: every line before the log's tail (output_tail:), which can print any line.
248
+ header = text.split(/^output_tail:$/, 2).first.to_s
249
+ return "stopped" if header.match?(/^(wait_result: canceled|status: stopped)$/)
250
+ return "error" if header[/^status: (\S+)$/, 1] == "failed"
251
+ end
234
252
 
235
253
  "ok"
236
254
  end
@@ -37,6 +37,12 @@ module Samagotchi
37
37
  # partial read by it. Kept in sync with head_line by its spec.
38
38
  HEAD_CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
39
39
 
40
+ # What a web row keeps of a tool's output, on a reload
41
+ # (Web::MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
42
+ # row shows 300 chars and the rest on hover. A longer one keeps its head
43
+ # and tail (preview).
44
+ WEB_PREVIEW_CHARS = 2000
45
+
40
46
  # The head's chars at most (execute.cut_head_chars): never more than a
41
47
  # quarter of the room; the tail gets the rest.
42
48
  HEAD_CHARS = 1000
@@ -55,10 +61,13 @@ module Samagotchi
55
61
  PREVIEW_MARK = "[TRUNCATED_PREVIEW_HEAD]"
56
62
  # execute's blocks (Tools::Execute#call): stdout, stderr, then the exit
57
63
  # line last (a hint in brackets may follow it). A killed or handed-over
58
- # command has none: its output isn't all there is.
64
+ # command has none: its output isn't all there is. A command a signal
65
+ # ended (a segfault, an OOM kill) has an exit line with no number
66
+ # ("exit: "): it ended, so its output is all there is. The JS twin is
67
+ # turn_html.js's EXIT_LINE.
59
68
  STDOUT_LABEL = "\nstdout:\n"
60
69
  STDERR_LABEL = "\nstderr:\n"
61
- EXIT_LINE = /\nexit: -?\d+[^\n]*(?:\n\([^\n]*\))?\z/
70
+ EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?\z/
62
71
 
63
72
  # The share of the room stdout gets when both blocks are cut.
64
73
  STDOUT_SHARE = Rational(2, 3)
@@ -230,13 +230,17 @@ module Samagotchi
230
230
  base.changed(add: kit.add, remove: kit.remove).with(name: name)
231
231
  end
232
232
 
233
+ # The keys a config kit entry takes.
234
+ KIT_KEYS = %w[base add remove].freeze
235
+
233
236
  # The kits known: the built-ins, then config's toolkit.kits, then the
234
237
  # plugins' (chi.toolkit). A config kit with a built-in's name is left
235
238
  # out (one it can't redefine) with a line in +errors+, and so is one
236
- # that isn't a {base:, add:, remove:} map.
239
+ # that isn't a {base:, add:, remove:} map. One with another key (a
240
+ # typo, a tools: list) still loads, with a line naming the key.
237
241
  # @param config [Hash, nil] toolkit.kits
238
242
  # @param plugins [Array<Kit>]
239
- # @param errors [Array<String>] gets a line per kit left out
243
+ # @param errors [Array<String>] gets a line per kit left out or unknown key
240
244
  # @return [Hash{String => Kit}]
241
245
  def self.kits(config: nil, plugins: [], errors: [])
242
246
  kits = BUILTIN_KITS.dup
@@ -248,6 +252,9 @@ module Samagotchi
248
252
  errors << "toolkit.kits.#{name}: expected {base:, add:, remove:}"
249
253
  else
250
254
  spec = spec.transform_keys(&:to_s)
255
+ (spec.keys - KIT_KEYS).each do |key|
256
+ errors << "toolkit.kits.#{name}: unknown key #{key} (a kit takes base:, add:, remove:)"
257
+ end
251
258
  kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
252
259
  source: "config")
253
260
  end
@@ -179,13 +179,24 @@ module Samagotchi
179
179
  return true if @index.sourced
180
180
  return false if toolkit&.removed&.include?(@index.call_name)
181
181
 
182
- @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
182
+ index_reaches?(toolkit)
183
183
  end
184
184
 
185
+ # Whether the index's tools would reach something under +toolkit+,
186
+ # removed or not: a source registered at load, or a built-in the
187
+ # toolkit leaves out without removing it.
188
+ def index_reaches?(toolkit)
189
+ @index.sourced || @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
190
+ end
191
+
192
+ # Not declared by +toolkit+ under +layers+. An index tool counts only
193
+ # when the toolkit removes it and it would reach something (full with
194
+ # --no-tool call_tool and no MCP server leaves nothing out).
185
195
  def left_out?(entry, layers, toolkit)
186
196
  return false unless entry.offered?(layers) && !declared?(entry, toolkit)
197
+ return true unless index_entry?(entry)
187
198
 
188
- !index_entry?(entry) || toolkit&.removed&.include?(entry.name) == true
199
+ toolkit&.removed&.include?(entry.name) == true && index_reaches?(toolkit)
189
200
  end
190
201
 
191
202
  # A built-in +toolkit+ leaves out of a turn under +layers+ without
@@ -80,13 +80,11 @@ module Samagotchi
80
80
  end
81
81
  private_class_method :cancelled_response
82
82
 
83
- # A task the user stopped also says so, with the tail of its output:
84
- # the model sees where the job was cut off.
83
+ # Always give the log's last lines: a test run's summary, an error. A
84
+ # task the user stopped also says so.
85
85
  def self.finished_response(record, tail_lines)
86
- return format_response(record) unless record.stopped_by_user?
87
-
88
- format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
89
- extra: [TaskRuntime::USER_STOP_NOTE])
86
+ extra = record.stopped_by_user? ? [TaskRuntime::USER_STOP_NOTE] : []
87
+ format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines, extra: extra)
90
88
  end
91
89
  private_class_method :finished_response
92
90
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.70.0"
4
+ VERSION = "0.71.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -896,9 +896,9 @@ module Samagotchi
896
896
  # session part is what the page reads of it (id, status, used memory
897
897
  # names, the model notes its prompt carried: the first turn's build or
898
898
  # a /model records them). ?turn_id=: that turn's answer (a queued
899
- # turn's page re-reads after the next one started). ?recent=1 (the page since this
900
- # release): the timing's records are the newest turn's (#recent_timing),
901
- # which the page merges by id; without it, the whole timing as before.
899
+ # turn's page re-reads after the next one started). The timing's records
900
+ # are the newest turn's (#recent_timing), which the page merges by id
901
+ # (the page still sends ?recent=1, which is accepted and not needed).
902
902
  def handle_tail_read(req, id)
903
903
  return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
904
904
 
@@ -919,9 +919,7 @@ module Samagotchi
919
919
  end
920
920
  session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
921
921
  live_metrics = state && state["metrics"]
922
- # Without recent=1: a tab opened before this release (it replaces its
923
- # timing with the reply's). Remove this branch after the next release.
924
- timing = req.params["recent"] == "1" ? recent_timing(id, live_metrics) : timing_payload(id, live_metrics: live_metrics)
922
+ timing = recent_timing(id, live_metrics)
925
923
  json_response(200, {
926
924
  tail: true,
927
925
  session: session_json,
@@ -1703,7 +1701,7 @@ module Samagotchi
1703
1701
  turn_count: (live && turn_count(live_metrics)) || turns.size)
1704
1702
  end
1705
1703
 
1706
- # The timing a ?tail=1&recent=1 read sends: a live worker's recent
1704
+ # The timing a ?tail=1 read sends: a live worker's recent
1707
1705
  # records as its metrics hold them (the newest finished turn's and
1708
1706
  # the unsaved ones, the running turn's finished calls), no
1709
1707
  # analytics.json read; with no worker the disk's, trimmed to the
@@ -53,7 +53,7 @@ module Samagotchi
53
53
  # The row shows 300 chars and the rest on hover; a read of a big file
54
54
  # would otherwise ride along with every reload. A longer one keeps its
55
55
  # head and tail (ToolOutputCut.preview).
56
- OUTPUT_MAX = 2000
56
+ OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
57
57
 
58
58
  QWEN_THINK = %r{<think>(.*?)</think>}m
59
59
  QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
@@ -67,6 +67,7 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
67
67
  if (typeof message.html === "string") {
68
68
  bubble.classList.add("markdown");
69
69
  bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ // The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
70
71
  bubble.innerHTML = message.html;
71
72
  }
72
73
  setAnswerRefs(bubble, message.display_refs);
@@ -162,6 +162,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
162
162
  .activity-alias{color:var(--text-faint);white-space:nowrap}
163
163
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
164
164
  .activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
165
+ .activity-output.tail{display:flex;flex-direction:column-reverse}
165
166
  /* A row's status and tool name never wrap (a phone broke them letter by
166
167
  letter); the params take what is left. The output goes on its own line
167
168
  under the call instead of a column beside it: on a desktop it lines up
@@ -11,6 +11,7 @@ import { activityDiffTemplate } from "./diff_view.js";
11
11
  import { clip, steerSender } from "./format.js";
12
12
  import { rowTitleRef } from "./refs.js";
13
13
  import { readLinesTemplate } from "./read_view.js";
14
+ import { handedOverId } from "./task_stop.js";
14
15
  import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
15
16
  import { emptyAnswerLine } from "./turn_events.js";
16
17
 
@@ -73,12 +74,63 @@ function llmEditTemplate(row) {
73
74
  return html`<div class="activity-edit" title=${hover}>${line}</div>`;
74
75
  }
75
76
 
77
+ // What a done row shows of its output inline: its first INLINE_CHARS, else
78
+ // for an execute's result and a task_wait with its log's last lines its
79
+ // last ones (a test run's summary, a failing exit line). An execute drops a
80
+ // bare success "exit: 0" (the mark says it) and, with no stderr block, the
81
+ // lone "stdout:" label. A guardrail deny, an approval refused or an
82
+ // execute's error has its reason first, a hand-over its task_id: the head.
83
+ // A gate-corrected call's `ran as:` line shows on hover only.
84
+ const INLINE_CHARS = 300;
85
+ // The JS twin of ToolOutputCut::EXIT_LINE (tool_output_cut.rb): an exit line, then maybe a hint; a
86
+ // signal-killed command's has no number ("exit: ").
87
+ const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
88
+ // A bare success exit line; group 1 is the hint line after it, if any.
89
+ const SUCCESS_EXIT_LINE = /\nexit: 0(\n\([^\n]*\))?$/;
90
+ const EXECUTE_BLOCK = /(?:^|\n)(?:stdout|stderr):\n/;
91
+ const OUTPUT_TAIL = /^output_tail:$/m;
92
+ // A gate-corrected call's first line (ToolRunner#changed_call_line) and the "[tool]" lead after it, which
93
+ // the row's lead strip misses (it isn't first). The inline text drops both; the hover keeps them.
94
+ const RAN_AS_LEAD = /^ran as: [^\n]*\n(?:\[[^\]\s]+\](?:\n| ))?/;
95
+
96
+ export function inlineOutput(tool, full) {
97
+ const text = String(full ?? "").replace(RAN_AS_LEAD, "");
98
+ if (!tailMode(tool, text)) return clip(text, INLINE_CHARS + 1);
99
+ let shown = text;
100
+ if (tool === "execute") {
101
+ shown = shown.replace(SUCCESS_EXIT_LINE, (_, hint) => hint || "");
102
+ if (!text.includes("\nstderr:\n") && shown.startsWith("stdout:\n")) shown = shown.slice("stdout:\n".length);
103
+ }
104
+ shown = shown.replace(/\n+$/, "");
105
+ return tailClip(shown.trim() ? shown : text);
106
+ }
107
+
108
+ // Whether a row's output shows its end (its div opens scrolled to the end).
109
+ export function tailMode(tool, text) {
110
+ if (tool === "task_wait") return OUTPUT_TAIL.test(text);
111
+ if (tool !== "execute" || handedOverId(text)) return false;
112
+ return EXECUTE_BLOCK.test(text) || EXIT_LINE.test(text);
113
+ }
114
+
115
+ // The text's end, as clip keeps its start: whole when it fits, else "…" and
116
+ // its last INLINE_CHARS, never from half an emoji, from a line's start when
117
+ // that keeps at least half of them (ToolOutputCut.cut_block's tail rule).
118
+ function tailClip(text) {
119
+ if (text.length <= INLINE_CHARS + 1) return text;
120
+ let kept = text.slice(-INLINE_CHARS);
121
+ const first = kept.charCodeAt(0);
122
+ if (first >= 0xdc00 && first <= 0xdfff) kept = kept.slice(1);
123
+ const nl = kept.indexOf("\n");
124
+ if (nl >= 0 && kept.length - nl - 1 >= INLINE_CHARS / 2) kept = kept.slice(nl + 1);
125
+ return `\u2026${kept}`;
126
+ }
127
+
76
128
  // A tool row: its status mark, tool, title (else params; the full command,
77
129
  // else the full params line, on hover), the word an exception shows, the
78
130
  // call's duration, the full command (an execute/task_create's view, also
79
131
  // while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
80
- // the rest on hover; a read of the file as-is also its numbered lines,
81
- // closed: read_view.js), its images (+thumbs+(images) draws them) and an
132
+ // an execute/task_wait's last ones (inlineOutput), the rest on hover; a read
133
+ // of the file as-is also its numbered lines, closed: read_view.js), its images (+thumbs+(images) draws them) and an
82
134
  // edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
83
135
  // part with nothing to say is left out. A reload renders toolRowTemplate;
84
136
  // the live view renders toolRowInnerTemplate into its own row element
@@ -107,7 +159,7 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
107
159
  const duration = formatDuration(row.duration_ms);
108
160
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
109
161
  const output = full
110
- ? html`<div class="activity-output" title=${full}>${clip(full, 301)}</div>`
162
+ ? html`<div class=${tailMode(row.tool, full) ? "activity-output tail" : "activity-output"} title=${full}>${inlineOutput(row.tool, full)}</div>`
111
163
  : nothing;
112
164
  const status = statusParts(row.status, { noMatch: !!row.no_match });
113
165
  return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
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.70.0
4
+ version: 0.71.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov