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 +4 -4
- data/CHANGELOG.md +39 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +4 -2
- data/docs/configuration.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +2 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/sessions_command.rb +12 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -1
- data/lib/samagotchi/tool_activity.rb +19 -1
- data/lib/samagotchi/tool_output_cut.rb +11 -2
- data/lib/samagotchi/toolkit.rb +9 -2
- data/lib/samagotchi/tools/registry.rb +13 -2
- data/lib/samagotchi/tools/task_wait.rb +4 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -7
- data/lib/samagotchi/web/message_parts.rb +1 -1
- data/lib/samagotchi/web/public/answer_refs.js +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/turn_html.js +55 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b92ddae3d906d7ba8192ddb77ac75b8d3f12e5d34ffc146f45ca18971a3df9d7
|
|
4
|
+
data.tar.gz: 668e0a999ef5cf276e8b40953a968301c84930399fb2e2ace30e34e18332641e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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).
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
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.
|
|
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;
|
|
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]
|
|
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`
|
|
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:
|
|
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
|
|
@@ -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
|
-
#
|
|
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.
|
|
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?
|
|
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
|
-
|
|
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:
|
|
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)
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
84
|
-
# the
|
|
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
|
-
|
|
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
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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).
|
|
900
|
-
#
|
|
901
|
-
#
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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}`;
|