samagotchi 0.28.0 → 0.29.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 +31 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundle_command.rb +4 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +4 -3
- data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -0
- data/lib/samagotchi/session_commands.rb +6 -2
- data/lib/samagotchi/tool_activity.rb +32 -6
- data/lib/samagotchi/tools/builtins.rb +10 -7
- data/lib/samagotchi/tools/task_runtime.rb +21 -0
- data/lib/samagotchi/turn_note.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +14 -4
- data/lib/samagotchi/web/public/index.html +37 -19
- data/lib/samagotchi/web/public/sessions_list.js +21 -4
- data/lib/samagotchi/web/public/stage_model.js +5 -2
- data/lib/samagotchi/web/public/turn_model.js +14 -5
- data/lib/samagotchi/web/public/turn_view.js +46 -10
- data/lib/samagotchi/worker.rb +9 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 581b7b0fa7184fb49a83588921656f2f91f05ae04538592e69874ba1207b686c
|
|
4
|
+
data.tar.gz: 4c381eb155ab69b933cda29461a02b111910adfb53309bb38ffc5819dfc764d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84b0ba1d65c4c5d41fcbee9e925934f6fd6ee1eb4b46bb35af98b46cde28f9ad334ab56ff44e1ee503f0f7f9dab107d03429c145c7097fa35f90aa8ab86f1f4f
|
|
7
|
+
data.tar.gz: 7725dc03f4d5c9d42e7a5c4cae0ba5729740a6d95d37fb04c7bdcc611489907b039ad2ec9b0770c7f47ff04fc681d750309988825896c0e90c1e5b7c0dda3605
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,35 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.29.0] - 2026-10-05
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Web: a session card shows `looped` when loop-guard stopped its last turn (`stopped by <name>` for another hook),
|
|
16
|
+
like `chi sessions list`'s `[looped]`; the all-sessions search finds them by those words.
|
|
17
|
+
- `chi bundle install` warns when a bundle needs a newer chi and its guardrail rules won't load (it already warned
|
|
18
|
+
about its hooks and plugin).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Web: a tool row shows its status as a mark (✓, ✕, ■, a spinner while it runs) instead of the word, so its command,
|
|
23
|
+
output and diff start right under the tool name; error, stopped and blocked calls keep their word after the title.
|
|
24
|
+
An open step's body sits indented under its summary.
|
|
25
|
+
- Web: a step with a failed call says so in its collapsed label, `3 tool calls (1 failed)`, with a red chevron; the
|
|
26
|
+
turn's summary counts failed calls below 3 calls too.
|
|
27
|
+
- Web: a `task_wait`, `task_get` or `task_stop` row names its task by the task's command instead of its id, a wait with
|
|
28
|
+
how long it waits (`bundle exec rspec · up to 600s`), also for a task started in an earlier turn; the id line stays
|
|
29
|
+
on hover.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Guardrails: `> /tmp/x cat <<EOF` (a redirection written with a space before the command) is read as a heredoc of
|
|
34
|
+
data like `cat > /tmp/x <<EOF`, and a `)` inside quotes or a heredoc body no longer ends a `$(…)` early.
|
|
35
|
+
- `!cmd` no longer hands a `--model` worker's model to a chi it runs as the default model.
|
|
36
|
+
- A session worker survives a failed save at the end of a turn (it logs it) instead of crashing.
|
|
37
|
+
- A turn that ends with an empty answer removes only its own retry note, not an earlier turn's cancel or failure note.
|
|
38
|
+
- Web: streamed chunks from llama.cpp no longer carry its whole echoed prompt to every web client.
|
|
39
|
+
|
|
11
40
|
## [0.28.0] - 2026-10-04
|
|
12
41
|
|
|
13
42
|
### Added
|
|
@@ -1471,7 +1500,8 @@ and long-lived sessions.
|
|
|
1471
1500
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1472
1501
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1473
1502
|
|
|
1474
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
1503
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.29.0...HEAD
|
|
1504
|
+
[0.29.0]: https://github.com/dm1try/samagotchi/compare/v0.28.0...v0.29.0
|
|
1475
1505
|
[0.28.0]: https://github.com/dm1try/samagotchi/compare/v0.27.0...v0.28.0
|
|
1476
1506
|
[0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
|
|
1477
1507
|
[0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
|
data/docs/sessions.md
CHANGED
|
@@ -56,7 +56,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
56
56
|
|
|
57
57
|
`--dry-run` is the safe preview. `--keep N` keeps the N newest sessions that stay: one that goes anyway (a leftover scratch session, one left empty, one older than `--days`) takes no place in the count. Web has no prune endpoint; use the CLI.
|
|
58
58
|
|
|
59
|
-
`chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
|
|
59
|
+
`chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` after the status dot (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
|
|
60
60
|
|
|
61
61
|
**Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
|
|
62
62
|
|
|
@@ -36,7 +36,8 @@ module Samagotchi
|
|
|
36
36
|
build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
|
|
37
37
|
Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
|
|
38
38
|
--scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
|
|
39
|
-
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones)
|
|
39
|
+
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones);
|
|
40
|
+
a named memory brings its model overlays (<name>.<model-key>.md).
|
|
40
41
|
|
|
41
42
|
trash [--empty] [--dry-run] [--older-than DAYS]
|
|
42
43
|
List bundle trash (moved files from uninstalls/upgrades).
|
|
@@ -61,7 +62,8 @@ module Samagotchi
|
|
|
61
62
|
build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
|
|
62
63
|
Build local memories and installed hooks into a shareable bundle (dir or zip).
|
|
63
64
|
--scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
|
|
64
|
-
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones)
|
|
65
|
+
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones);
|
|
66
|
+
a named memory brings its model overlays (<name>.<model-key>.md).
|
|
65
67
|
|
|
66
68
|
Examples:
|
|
67
69
|
chi bundle build --scope system
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1942,11 +1942,12 @@ module Samagotchi
|
|
|
1942
1942
|
# that way: the note, and nothing made up as its answer. The note's
|
|
1943
1943
|
# marker is for the UIs (the notice, and the empty steps after a
|
|
1944
1944
|
# reload); it goes on the result's conversation too (the REPL keeps it).
|
|
1945
|
-
# A retry's nudge at the tail goes: this note says it all.
|
|
1945
|
+
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
1946
|
+
# turn's note stays: it says why that turn ended.
|
|
1946
1947
|
if result.empty_answer?
|
|
1947
1948
|
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
|
|
1948
|
-
conversation&.replace(TurnNote.
|
|
1949
|
-
saved = TurnNote.
|
|
1949
|
+
conversation&.replace(TurnNote.without_trailing_nudge(conversation))
|
|
1950
|
+
saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
|
|
1950
1951
|
conversation << note if conversation
|
|
1951
1952
|
saved + [note]
|
|
1952
1953
|
elsif result.canceled? && conversation
|
|
@@ -169,19 +169,76 @@ module Samagotchi
|
|
|
169
169
|
add(SUBST, close + 1 - @i)
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
+
# From $( to the ) that closes it, as one SUBST. A quote or a
|
|
173
|
+
# heredoc body inside it is skipped over: a ) in one of those
|
|
174
|
+
# doesn't close the substitution.
|
|
172
175
|
def substitution
|
|
173
176
|
depth = 0
|
|
174
177
|
j = @i + 1
|
|
175
178
|
loop do
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
break if j >= @s.size
|
|
180
|
+
|
|
181
|
+
c = @s[j]
|
|
182
|
+
if ["'", '"'].include?(c)
|
|
183
|
+
j = quote_end(j)
|
|
184
|
+
elsif c == "("
|
|
185
|
+
depth += 1
|
|
186
|
+
j += 1
|
|
187
|
+
elsif c == ")"
|
|
188
|
+
depth -= 1
|
|
189
|
+
j += 1
|
|
190
|
+
break if depth.zero?
|
|
191
|
+
elsif @s[j, 2] == "<<"
|
|
192
|
+
j = substitution_heredoc(j)
|
|
193
|
+
else
|
|
194
|
+
j += 1
|
|
195
|
+
end
|
|
180
196
|
end
|
|
181
197
|
@unterminated = true unless depth.zero?
|
|
182
198
|
add(SUBST, j - @i)
|
|
183
199
|
end
|
|
184
200
|
|
|
201
|
+
# The index after the quote starting at +j+, a backslash in a double
|
|
202
|
+
# quote escaping the next character; the text's end when it is never
|
|
203
|
+
# closed.
|
|
204
|
+
def quote_end(j)
|
|
205
|
+
quote = @s[j]
|
|
206
|
+
k = j + 1
|
|
207
|
+
while k < @s.size
|
|
208
|
+
if quote == '"' && @s[k] == "\\" then k += 2
|
|
209
|
+
elsif @s[k] == quote then return k + 1
|
|
210
|
+
else k += 1
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
k
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# From a << at +j+ inside a $(…) past its body's terminator line
|
|
217
|
+
# (nothing is lexed: the body is only stepped over), or on to the
|
|
218
|
+
# next character when no tag follows or the terminator never comes
|
|
219
|
+
# (a << that is no heredoc, as arithmetic's 1<<3, stays scanned).
|
|
220
|
+
def substitution_heredoc(j)
|
|
221
|
+
dash = @s[j + 2] == "-"
|
|
222
|
+
k = j + (dash ? 3 : 2)
|
|
223
|
+
k += 1 while [" ", "\t"].include?(@s[k])
|
|
224
|
+
tag, _quoted, stop = heredoc_tag(k)
|
|
225
|
+
return j + 1 if tag.empty?
|
|
226
|
+
|
|
227
|
+
eol = @s.index("\n", stop)
|
|
228
|
+
return j + 1 if eol.nil?
|
|
229
|
+
|
|
230
|
+
k = eol + 1
|
|
231
|
+
while k < @s.size
|
|
232
|
+
eol = @s.index("\n", k) || @s.size
|
|
233
|
+
line = @s[k...eol]
|
|
234
|
+
line = line.sub(/\A\t+/, "") if dash
|
|
235
|
+
return [eol + 1, @s.size].min if line == tag
|
|
236
|
+
|
|
237
|
+
k = eol + 1
|
|
238
|
+
end
|
|
239
|
+
j + 1
|
|
240
|
+
end
|
|
241
|
+
|
|
185
242
|
def comment
|
|
186
243
|
@i = @s.index("\n", @i) || @s.size
|
|
187
244
|
end
|
|
@@ -306,10 +363,27 @@ module Samagotchi
|
|
|
306
363
|
def data_sink?(at)
|
|
307
364
|
start = at
|
|
308
365
|
start -= 1 while start.positive? && @tokens[start - 1][0] == :word
|
|
309
|
-
verb = @tokens[start...at]
|
|
366
|
+
verb = command_word(@tokens[start...at])
|
|
310
367
|
next_op = @tokens[at..].find { |t| t[0] == :op }
|
|
311
368
|
DATA_SINKS.include?(File.basename(verb.to_s)) && !["|", "|&"].include?(next_op&.dig(1))
|
|
312
369
|
end
|
|
370
|
+
|
|
371
|
+
# The command word among the words before a heredoc's: the NAME=
|
|
372
|
+
# words and the redirections dropped, and with a redirection written
|
|
373
|
+
# with a space (> /tmp/x) its target word too.
|
|
374
|
+
def command_word(words)
|
|
375
|
+
i = 0
|
|
376
|
+
while i < words.size
|
|
377
|
+
word = words[i][1]
|
|
378
|
+
if word.match?(REDIRECT)
|
|
379
|
+
i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
|
|
380
|
+
elsif !word.match?(/\A[A-Za-z_]\w*=/)
|
|
381
|
+
return word
|
|
382
|
+
end
|
|
383
|
+
i += 1
|
|
384
|
+
end
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
313
387
|
end
|
|
314
388
|
end
|
|
315
389
|
end
|
|
@@ -372,13 +372,20 @@ module Samagotchi
|
|
|
372
372
|
return unless turn.on_stream_event
|
|
373
373
|
|
|
374
374
|
event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
|
|
375
|
-
thinking: split[:thinking], payload: chunk[:payload] }
|
|
375
|
+
thinking: split[:thinking], payload: event_payload(chunk[:payload]) }
|
|
376
376
|
# A chunk of a tool call (its bytes are dropped from both lanes): a
|
|
377
377
|
# steer doesn't cut this generation (Engine#cut_for_steer).
|
|
378
378
|
event[:tool_call] = true if split[:tool]
|
|
379
379
|
emit(turn, **event)
|
|
380
380
|
end
|
|
381
381
|
|
|
382
|
+
# The payload a :generation_chunk event carries: llama.cpp's last chunk
|
|
383
|
+
# echoes the whole rendered prompt, which nothing reads and every SSE
|
|
384
|
+
# client would get (the kernel's own reads keep the full payload).
|
|
385
|
+
def event_payload(payload)
|
|
386
|
+
payload.is_a?(Hash) ? payload.except("prompt") : payload
|
|
387
|
+
end
|
|
388
|
+
|
|
382
389
|
# The payload's prompt-cache counts, kept as the largest seen.
|
|
383
390
|
def note_cache_counts(generation, payload)
|
|
384
391
|
counts = TokenUsage.from_payload(payload)
|
|
@@ -303,6 +303,7 @@ module Samagotchi
|
|
|
303
303
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
304
304
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
305
305
|
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
306
|
+
warn_rules_requires_chi(manifest, incoming_rules)
|
|
306
307
|
|
|
307
308
|
# Files the previous version shipped and this one doesn't.
|
|
308
309
|
if @upgrade && existing_provenance && existing_provenance[:files]
|
|
@@ -559,6 +560,19 @@ module Samagotchi
|
|
|
559
560
|
end
|
|
560
561
|
end
|
|
561
562
|
|
|
563
|
+
# GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
|
|
564
|
+
# requires_chi this chi doesn't meet, and a session that never loaded
|
|
565
|
+
# them records a required load failure (the gate refuses guarded calls
|
|
566
|
+
# until chi is updated): say so at install, as the hooks warning does.
|
|
567
|
+
def warn_rules_requires_chi(manifest, rule_files)
|
|
568
|
+
return if manifest.nil? || rule_files.empty?
|
|
569
|
+
|
|
570
|
+
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
571
|
+
@warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
|
|
572
|
+
"(chi update), guarded tool calls are refused"
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
562
576
|
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
563
577
|
# warning when its sha256 differs from the declared one or this chi
|
|
564
578
|
# doesn't meet requires_chi (the Engine then won't load it).
|
|
@@ -5,6 +5,7 @@ require_relative "model_profile"
|
|
|
5
5
|
require_relative "served_model"
|
|
6
6
|
require_relative "turn_flow"
|
|
7
7
|
require_relative "tools/execute"
|
|
8
|
+
require_relative "tools/builtins"
|
|
8
9
|
require_relative "commands/registry"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -207,12 +208,15 @@ module Samagotchi
|
|
|
207
208
|
end
|
|
208
209
|
|
|
209
210
|
# The output goes into the conversation for the next turn. Not saved
|
|
210
|
-
# here (as in the REPL, the next turn saves it).
|
|
211
|
+
# here (as in the REPL, the next turn saves it). No parent-session
|
|
212
|
+
# marker: the user runs this, not the model. A worker's `chi --model X`
|
|
213
|
+
# default is unset, though: it was that run's, not the default of a chi
|
|
214
|
+
# run here (Tools::Builtins.cli_model_unset).
|
|
211
215
|
def shell(text)
|
|
212
216
|
command = text.delete_prefix(SHELL_BANG_PREFIX).strip
|
|
213
217
|
return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
|
|
214
218
|
|
|
215
|
-
output = Samagotchi::Tools::Execute.call(command)
|
|
219
|
+
output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
|
|
216
220
|
@engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
|
|
217
221
|
# Rolling back past this would silently drop the command output.
|
|
218
222
|
@turn_flow.note_conversation_changed
|
|
@@ -12,6 +12,7 @@ require_relative "tools/edit"
|
|
|
12
12
|
require_relative "tools/task_create"
|
|
13
13
|
require_relative "tools/task_get"
|
|
14
14
|
require_relative "tools/task_list"
|
|
15
|
+
require_relative "tools/task_runtime"
|
|
15
16
|
require_relative "tools/task_stop"
|
|
16
17
|
require_relative "tools/task_wait"
|
|
17
18
|
require_relative "tools/web_fetch"
|
|
@@ -62,11 +63,13 @@ module Samagotchi
|
|
|
62
63
|
# command without its "cd … &&"): a file tool's path relative to +cwd+
|
|
63
64
|
# when it is under it, a command's description (the model's few words),
|
|
64
65
|
# else its first step and how many more (CommandSteps; its first line
|
|
65
|
-
# when it has no steps), a memory's name
|
|
66
|
-
# nil for the other tools (a plugin's row keeps its preview, the
|
|
66
|
+
# when it has no steps), a memory's name, a task tool's task command
|
|
67
|
+
# (task_title); nil for the other tools (a plugin's row keeps its preview, the
|
|
67
68
|
# params). +params+ stays the full key=value line (the TUI, the
|
|
68
69
|
# guardrails).
|
|
69
70
|
def tool_title(tool_name, call, cwd: nil)
|
|
71
|
+
return task_title(tool_name, call, cwd) if TASK_TOOLS.include?(tool_name)
|
|
72
|
+
|
|
70
73
|
if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
|
|
71
74
|
title = command_description(tool_name, call) || steps_title(call[:content])
|
|
72
75
|
return title if title
|
|
@@ -90,19 +93,42 @@ module Samagotchi
|
|
|
90
93
|
end
|
|
91
94
|
end
|
|
92
95
|
|
|
96
|
+
TASK_TOOLS = [Tools::TaskWait::NAME, Tools::TaskGet::NAME, Tools::TaskStop::NAME].freeze
|
|
97
|
+
|
|
98
|
+
# A task_wait / task_get / task_stop row's title: the task's command
|
|
99
|
+
# (its record under +cwd+, the process's directory when nil) as a
|
|
100
|
+
# command's title, and for a wait how long it waits ("npm test · up to
|
|
101
|
+
# 600s"). nil when there is no record: the row keeps its id.
|
|
102
|
+
def task_title(tool_name, call, cwd)
|
|
103
|
+
command = Tools::TaskRuntime.command_of(call[:content], root: cwd)
|
|
104
|
+
return nil unless command
|
|
105
|
+
|
|
106
|
+
suffix = ""
|
|
107
|
+
if tool_name == Tools::TaskWait::NAME
|
|
108
|
+
timeout = call[:timeout].to_s.strip
|
|
109
|
+
suffix = " · up to #{timeout.empty? ? Tools::TaskWait::TIMEOUT_DEFAULT : timeout.to_i}s"
|
|
110
|
+
end
|
|
111
|
+
room = TOOL_ACTIVITY_PREVIEW_LIMIT - suffix.length
|
|
112
|
+
text = steps_title(command, room) || command_title(command).gsub(/\s+/, " ")
|
|
113
|
+
return nil if text.empty?
|
|
114
|
+
|
|
115
|
+
text = "#{text[0, room - 1]}…" if text.length > room
|
|
116
|
+
"#{text}#{suffix}"
|
|
117
|
+
end
|
|
118
|
+
|
|
93
119
|
# A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
|
|
94
120
|
# eats the line.
|
|
95
121
|
LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
|
|
96
122
|
|
|
97
|
-
# "<first step> +N" (N the steps after it), cut to fit
|
|
98
|
-
# TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
|
|
99
|
-
def steps_title(command)
|
|
123
|
+
# "<first step> +N" (N the steps after it), cut to fit +limit+
|
|
124
|
+
# (TOOL_ACTIVITY_PREVIEW_LIMIT); nil when the command has no steps.
|
|
125
|
+
def steps_title(command, limit = TOOL_ACTIVITY_PREVIEW_LIMIT)
|
|
100
126
|
steps = CommandSteps.parse(command)&.steps
|
|
101
127
|
return nil unless steps
|
|
102
128
|
|
|
103
129
|
first = steps.first.text.gsub(/\s+/, " ")
|
|
104
130
|
more = steps.size > 1 ? " +#{steps.size - 1}" : ""
|
|
105
|
-
room =
|
|
131
|
+
room = limit - more.length
|
|
106
132
|
first = "#{first[0, room - 1]}…" if first.length > room
|
|
107
133
|
"#{first}#{more}"
|
|
108
134
|
end
|
|
@@ -117,13 +117,16 @@ module Samagotchi
|
|
|
117
117
|
peers = kctx.peers
|
|
118
118
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
119
119
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
120
|
+
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
121
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model }.merge(cli_model_unset(env))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The unsets that keep a `chi --model X` worker's default from passing
|
|
125
|
+
# for the default of a chi run here (nothing when it wasn't the CLI's).
|
|
126
|
+
def cli_model_unset(env = ENV)
|
|
127
|
+
return {} unless env[ModelProfile::MODEL_FROM_CLI_ENV]
|
|
128
|
+
|
|
129
|
+
{ ModelProfile::MODEL_ENV => nil, ModelProfile::MODEL_FROM_CLI_ENV => nil }
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
@@ -129,6 +129,27 @@ module Samagotchi
|
|
|
129
129
|
end.uniq
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
# A task id as task_create makes them: no path parts.
|
|
133
|
+
TASK_ID = /\A[A-Za-z0-9_-]+\z/
|
|
134
|
+
|
|
135
|
+
# The command of the task +task_id+ under +root+ (a session's working
|
|
136
|
+
# directory) as its record has it, for a UI row's title; nil for no
|
|
137
|
+
# such task or an unreadable record. Reads the record only (no
|
|
138
|
+
# refresh), so it is cheap and never fails.
|
|
139
|
+
# @return [String, nil]
|
|
140
|
+
def command_of(task_id, root: Dir.pwd)
|
|
141
|
+
id = task_id.to_s.strip
|
|
142
|
+
return nil unless id.match?(TASK_ID)
|
|
143
|
+
|
|
144
|
+
path = File.join(root || Dir.pwd, TASKS_DIR, id, METADATA_FILENAME)
|
|
145
|
+
return nil unless File.file?(path)
|
|
146
|
+
|
|
147
|
+
command = JSON.parse(File.read(path))["command"]
|
|
148
|
+
command.is_a?(String) && !command.strip.empty? ? command : nil
|
|
149
|
+
rescue StandardError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
132
153
|
def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
|
|
133
154
|
|
|
134
155
|
def get_record(task_id)
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -188,6 +188,16 @@ module Samagotchi
|
|
|
188
188
|
list
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
+
# A copy of +messages+ without the note at its tail when that note is a
|
|
192
|
+
# retry nudge (anything else stays: an earlier turn's note is what says
|
|
193
|
+
# why that turn ended).
|
|
194
|
+
def without_trailing_nudge(messages)
|
|
195
|
+
list = Array(messages).dup
|
|
196
|
+
index = trailing_index(list)
|
|
197
|
+
list.delete_at(index) if index && retry_nudge?(list[index])
|
|
198
|
+
list
|
|
199
|
+
end
|
|
200
|
+
|
|
191
201
|
# The index of the note at the tail of +list+ (behind context notes at
|
|
192
202
|
# most), or nil.
|
|
193
203
|
def trailing_index(list)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -39,7 +39,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
|
39
39
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
40
40
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
41
41
|
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
42
|
-
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
42
|
+
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
43
43
|
import { createNotifications } from "./notifications.js";
|
|
44
44
|
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
45
45
|
import {
|
|
@@ -328,8 +328,12 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
328
328
|
const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
|
|
329
329
|
const waiting = waitingBadge(s);
|
|
330
330
|
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
331
|
+
// A turn a hook stopped wears its own badge, unless the session waits on
|
|
332
|
+
// the user (the waiting badge wins: the stop is behind the need).
|
|
333
|
+
const stopped = waiting ? null : stoppedBadge(s);
|
|
334
|
+
const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
|
|
331
335
|
const dotWord = waiting ? waiting.title : s.status;
|
|
332
|
-
return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
|
|
336
|
+
return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
// A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
|
|
@@ -1483,6 +1487,10 @@ function clearTimingInterval() {
|
|
|
1483
1487
|
}
|
|
1484
1488
|
|
|
1485
1489
|
let liveTurnNumber = null;
|
|
1490
|
+
// The live line sits in the stage's status row, which already says the turn
|
|
1491
|
+
// runs ("running a tool"): it drops its own "running" there. It never moves
|
|
1492
|
+
// mid-turn (finishLiveTurnTiming rewrites it before any hand-off).
|
|
1493
|
+
let liveTurnTimingInStage = false;
|
|
1486
1494
|
|
|
1487
1495
|
function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
|
|
1488
1496
|
clearTimingInterval();
|
|
@@ -1495,7 +1503,9 @@ function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null
|
|
|
1495
1503
|
liveTurnTimingEl.className = "turn-timing live";
|
|
1496
1504
|
// The stage's status row while it holds the turn: the line moves into the
|
|
1497
1505
|
// history with it.
|
|
1498
|
-
|
|
1506
|
+
const stageHost = turnDom.timingHost?.() || null;
|
|
1507
|
+
liveTurnTimingInStage = !!stageHost;
|
|
1508
|
+
(stageHost || historyEl).appendChild(liveTurnTimingEl);
|
|
1499
1509
|
updateLiveTiming();
|
|
1500
1510
|
timingInterval = setInterval(updateLiveTiming, 1000);
|
|
1501
1511
|
}
|
|
@@ -1509,7 +1519,7 @@ function appendToHistory(el) {
|
|
|
1509
1519
|
function updateLiveTiming() {
|
|
1510
1520
|
const duration = elapsedSince(current.timing.activeTurn?.started_at);
|
|
1511
1521
|
if (liveTurnTimingEl && duration !== null) {
|
|
1512
|
-
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running:
|
|
1522
|
+
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: !liveTurnTimingInStage }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
|
|
1513
1523
|
}
|
|
1514
1524
|
updateInfoBar();
|
|
1515
1525
|
}
|
|
@@ -127,40 +127,50 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
127
127
|
.bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
|
|
128
128
|
.bubble.recap.stale{opacity:.7;border-left-style:dashed}
|
|
129
129
|
.activity-body{margin-top:6px}
|
|
130
|
-
.activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
|
|
130
|
+
.activity-row{--row-gutter:22px;display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
|
|
131
131
|
.activity-row:first-child{border-top:none}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.activity-
|
|
136
|
-
.activity-status
|
|
137
|
-
.activity-status.
|
|
132
|
+
/* A row's status is a mark in a 14 px slot, drawn from its class (no text: a copy or an annotate quote gets
|
|
133
|
+
no glyph; the word is its aria-label and title). The glyphs the stage already uses: done ✓, error and
|
|
134
|
+
blocked ✕, stopped ■, running its border spinner (still once the turn is over: a dead worker's row).
|
|
135
|
+
An exception also shows its word after the title (.activity-state). */
|
|
136
|
+
.activity-status{width:14px;text-align:center;font-size:12px;font-weight:700}
|
|
137
|
+
.activity-status.ok::before{content:"\2713";opacity:.85}
|
|
138
|
+
.activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
|
|
139
|
+
.activity-status.stopped::before{content:"\25A0";font-size:10px}
|
|
140
|
+
.activity-status.running::before{content:"";display:inline-block;box-sizing:border-box;width:10px;height:10px;vertical-align:-1px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%}
|
|
141
|
+
.turn-work:not(.done) .activity-status.running::before{animation:init-spin .8s linear infinite}
|
|
142
|
+
.activity-state{flex:none;white-space:nowrap;font-size:11px;text-transform:uppercase;letter-spacing:.03em;font-weight:600}
|
|
143
|
+
:is(.activity-status,.activity-state).running{color:var(--warn)}
|
|
144
|
+
:is(.activity-status,.activity-state).ok{color:var(--ok)}
|
|
145
|
+
:is(.activity-status,.activity-state).error{color:var(--danger)}
|
|
146
|
+
:is(.activity-status,.activity-state).stopped{color:var(--warn-text);opacity:.8}
|
|
147
|
+
:is(.activity-status,.activity-state).blocked{color:var(--danger-text)}
|
|
138
148
|
.activity-tool{color:var(--accent);font-weight:600}
|
|
139
149
|
/* The stop-task button on a running task_wait (task_stop.js): a small ghost
|
|
140
150
|
button in the danger colours, at the row's end and in the stage's now slot. */
|
|
141
151
|
button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,transparent);border-radius:6px;background:transparent;color:var(--danger-text);font-size:11.5px;line-height:18px;white-space:nowrap}
|
|
142
152
|
button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
|
|
143
153
|
.ts-now button.task-stop{align-self:center}
|
|
144
|
-
.activity-params{color:var(--text-muted);
|
|
154
|
+
.activity-params{color:var(--text-muted);overflow-wrap:anywhere}
|
|
145
155
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
146
|
-
.activity-output{margin:4px 0 2px
|
|
156
|
+
.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}
|
|
147
157
|
/* A row's status and tool name never wrap (a phone broke them letter by
|
|
148
158
|
letter); the params take what is left. The output goes on its own line
|
|
149
159
|
under the call instead of a column beside it: on a desktop it lines up
|
|
150
|
-
under the tool name (the
|
|
151
|
-
takes the full width and the params break inside the rest of
|
|
152
|
-
line rather than drop to a line of their own. */
|
|
160
|
+
under the tool name (--row-gutter: the 14 px mark slot + the 8 px gap), on
|
|
161
|
+
a phone it takes the full width and the params break inside the rest of
|
|
162
|
+
the first line rather than drop to a line of their own. */
|
|
153
163
|
.activity-row{flex-wrap:wrap}
|
|
154
164
|
.activity-status,.activity-tool{flex-shrink:0;white-space:nowrap}
|
|
155
165
|
.activity-params{min-width:0}
|
|
156
166
|
.activity-output{flex:1 1 100%;min-width:0}
|
|
157
167
|
/* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
|
|
158
|
-
.activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px
|
|
168
|
+
.activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
|
|
159
169
|
.activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
|
|
160
170
|
.activity-diff>summary:hover{color:var(--text-2)}
|
|
161
171
|
.activity-diff[open]>summary{margin-bottom:4px}
|
|
162
172
|
/* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
|
|
163
|
-
.activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px
|
|
173
|
+
.activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px var(--row-gutter)}
|
|
164
174
|
.activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
|
|
165
175
|
.activity-command-cwd,.activity-command-cut{font-size:11px;color:var(--text-faint)}
|
|
166
176
|
.activity-command-cwd{margin-bottom:2px}
|
|
@@ -218,12 +228,17 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
218
228
|
.turn-work:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);align-self:flex-start;border-radius:6px;transition:background .12s ease}
|
|
219
229
|
.turn-work:not([open]):hover{background:var(--bg-panel)}
|
|
220
230
|
.turn-work:not([open])>summary{font-size:12px;font-weight:500}
|
|
221
|
-
|
|
222
|
-
.
|
|
231
|
+
/* A step's body (thinking, narration, rows, cards) sits --step-indent right of its summary, so the thinking
|
|
232
|
+
chevron and the row marks share one column under the step's own chevron. The summary takes it back with a
|
|
233
|
+
negative margin: a child's own margin shorthand can't undo the padding. */
|
|
234
|
+
.gen{--step-indent:14px;margin-top:8px;padding-top:6px;padding-left:var(--step-indent);border-top:1px solid var(--border)}
|
|
235
|
+
.gen>summary{margin-left:calc(-1 * var(--step-indent));cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
223
236
|
.gen>summary::-webkit-details-marker{display:none}
|
|
224
237
|
.gen>summary::before{content:"▸ ";color:var(--text-faint)}
|
|
225
238
|
.gen[open]>summary::before{content:"▾ "}
|
|
226
|
-
|
|
239
|
+
/* A step with a failed call (status error): its chevron in the danger colour. */
|
|
240
|
+
.gen.has-failed>summary::before{color:var(--danger)}
|
|
241
|
+
.gen.live{border-top-color:transparent;background:var(--accent-tint);border-radius:8px;padding:6px 9px 8px calc(9px + var(--step-indent));margin-left:-9px;margin-right:-9px}
|
|
227
242
|
.gen.live>summary{color:var(--accent);white-space:normal}
|
|
228
243
|
/* The live step's marker: a pulsing accent dot in place of the chevron (the block's own left bar stays the only one). */
|
|
229
244
|
.gen.live>summary::before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 2px;border-radius:50%;background:var(--accent);vertical-align:middle;animation:step-pulse 1.6s ease-in-out infinite}
|
|
@@ -256,6 +271,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
256
271
|
.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body))>summary,.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen>summary{display:none}
|
|
257
272
|
.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen{margin:0;padding:0;border:0;background:transparent}
|
|
258
273
|
.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen-text{margin:0;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:12px 14px;opacity:.95}
|
|
274
|
+
@media(max-width:600px){.gen{--step-indent:8px}}
|
|
259
275
|
/* A question card asks for input like the composer card, so it wears the same glass (the recipe above); only its warm tint is its own. */
|
|
260
276
|
.bubble.question{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg));padding:10px 12px}
|
|
261
277
|
/* Collapsed (resolved) question: one line, like the thinking and activity blocks.
|
|
@@ -432,7 +448,8 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
432
448
|
.chip .chip-remove{background:transparent;color:var(--text-3);padding:0 6px;min-height:0;font-size:14px}
|
|
433
449
|
.thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
|
|
434
450
|
.thumb{max-height:120px;max-width:200px;border-radius:6px;border:1px solid var(--border-strong);cursor:zoom-in;object-fit:contain;background:var(--bg)}
|
|
435
|
-
.activity-row .thumbs{flex-basis:100
|
|
451
|
+
.activity-row .thumbs{flex-basis:100%;margin-left:var(--row-gutter)}
|
|
452
|
+
@media(max-width:600px){.activity-row .thumbs{margin-left:0}}
|
|
436
453
|
#lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
|
|
437
454
|
#lightbox[hidden]{display:none}
|
|
438
455
|
#lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
|
|
@@ -617,7 +634,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
617
634
|
.ts-defer{display:none}
|
|
618
635
|
.ts-trail-item{max-width:24ch}
|
|
619
636
|
}
|
|
620
|
-
@media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark{animation:none}.turn-stage.leaving{transition:none}}
|
|
637
|
+
@media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving{transition:none}}
|
|
621
638
|
#infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
|
|
622
639
|
#infoBar.visible{display:flex}
|
|
623
640
|
#infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
|
|
@@ -809,6 +826,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
809
826
|
@keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
|
|
810
827
|
@media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting{animation:none}}
|
|
811
828
|
.card .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
|
|
829
|
+
.card .stopped-badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn);color:var(--bg)}
|
|
812
830
|
.status.running{background:var(--ok-bg);color:var(--ok-text)}
|
|
813
831
|
.status.idle{background:var(--bg-raised)}
|
|
814
832
|
.status.completed{background:var(--accent-bg);color:var(--accent)}
|
|
@@ -89,12 +89,29 @@ export function waitingBadge(summary) {
|
|
|
89
89
|
return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// "" for
|
|
92
|
+
// A card's badge for a last turn a hook stopped (the same marker as
|
|
93
|
+
// `chi sessions list`'s "[looped]" / "[stopped by X]"): "looped" for
|
|
94
|
+
// loop-guard, "stopped by <name>" for any other hook; null when the last
|
|
95
|
+
// turn ended otherwise.
|
|
96
|
+
// @return null | {text, title}
|
|
97
|
+
export function stoppedBadge(summary) {
|
|
98
|
+
const by = summary?.last_turn?.stopped_by;
|
|
99
|
+
if (!by) return null;
|
|
100
|
+
return by === "loop-guard"
|
|
101
|
+
? { text: "looped", title: "loop-guard stopped the last turn" }
|
|
102
|
+
: { text: `stopped by ${by}`, title: `${by} stopped the last turn` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// What the all view's search matches for a session: its waiting badge's words
|
|
106
|
+
// ("waiting", "question", "approval", …) and/or its stopped badge's words
|
|
107
|
+
// ("looped", "stopped by <name>"); "" for one with neither.
|
|
95
108
|
export function waitingSearchText(summary) {
|
|
109
|
+
const parts = [];
|
|
96
110
|
const badge = waitingBadge(summary);
|
|
97
|
-
|
|
111
|
+
if (badge) parts.push(`waiting ${badge.text}`);
|
|
112
|
+
const stopped = stoppedBadge(summary);
|
|
113
|
+
if (stopped) parts.push(stopped.text);
|
|
114
|
+
return parts.join(" ");
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
// The sessions that wait on the user (waitingOn) first, the rest after,
|
|
@@ -11,6 +11,7 @@ const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task
|
|
|
11
11
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
12
12
|
const EXECS = new Set(["execute", "task_create", "task_wait", "task_stop", "delegate", "delegate_result"]);
|
|
13
13
|
const FILE_TOOLS = new Set(["read", "edit", "write"]);
|
|
14
|
+
const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
|
|
14
15
|
const TRAIL = 3;
|
|
15
16
|
// The hand-off waits this long after the stage was last in use.
|
|
16
17
|
export const QUIET_MS = 1500;
|
|
@@ -80,9 +81,11 @@ function rowTitle(row) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
// A slot's full command (the row's view), for its hover; none for a tool
|
|
83
|
-
// without one.
|
|
84
|
+
// without one. A task tool titled by its task's command (the server's
|
|
85
|
+
// title) hovers its params instead: the id line it replaced.
|
|
84
86
|
function withCommand(slot, row) {
|
|
85
|
-
|
|
87
|
+
if (row.view?.command) return { ...slot, command: row.view.command };
|
|
88
|
+
return TASK_TOOLS.has(row.tool) && row.title && row.params ? { ...slot, command: row.params } : slot;
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
// A running task_wait's slot: the task it waits on and the row's key, for
|
|
@@ -159,13 +159,22 @@ function cut(line) {
|
|
|
159
159
|
return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// "3 tool calls (1 failed)": failed = status error, the tally's predicate
|
|
163
|
+
// (tally.js), so a step's label and the block's summary never disagree.
|
|
164
|
+
function toolCallsText(rows) {
|
|
165
|
+
const n = rows.length;
|
|
166
|
+
const failed = failedCount(rows);
|
|
167
|
+
return `${n} tool call${n === 1 ? "" : "s"}${failed ? ` (${failed} failed)` : ""}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A gen's calls that failed (status error; blocked and stopped don't count).
|
|
171
|
+
export function failedCount(rows) {
|
|
172
|
+
return rows.filter((r) => r.status === "error").length;
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
// The collapsed row of a gen: its narration's first line, else its first
|
|
167
176
|
// call's title ("edit lib/a.rb"), else the tools it worked with, else
|
|
168
|
-
// "thinking"; then its call count.
|
|
177
|
+
// "thinking"; then its call count and how many failed.
|
|
169
178
|
export function genLabel(gen) {
|
|
170
179
|
const line = String(gen.text || "").trim().split("\n")[0].trim();
|
|
171
180
|
const calls = gen.tools.length;
|
|
@@ -181,7 +190,7 @@ export function genLabel(gen) {
|
|
|
181
190
|
} else {
|
|
182
191
|
return "thinking";
|
|
183
192
|
}
|
|
184
|
-
return calls ? `${head} · ${toolCallsText(
|
|
193
|
+
return calls ? `${head} · ${toolCallsText(gen.tools)}` : head;
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
// The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
|
|
@@ -192,7 +201,7 @@ export function blockSummary(turn, { running = false } = {}) {
|
|
|
192
201
|
const fields = [`${steps} step${steps === 1 ? "" : "s"}`];
|
|
193
202
|
const tally = tallyText(rows, { last: false });
|
|
194
203
|
if (tally) fields.push(tally);
|
|
195
|
-
else if (rows.length) fields.push(toolCallsText(rows
|
|
204
|
+
else if (rows.length) fields.push(toolCallsText(rows));
|
|
196
205
|
if (running) fields.unshift("working");
|
|
197
206
|
return fields.join(" · ");
|
|
198
207
|
}
|
|
@@ -30,19 +30,44 @@ import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
|
30
30
|
// denied it), else ok ("done").
|
|
31
31
|
const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// A row's status as its parts: the mark's class (CSS draws the glyph), its
|
|
34
|
+
// label (aria-label and title) and the word an exception shows after the
|
|
35
|
+
// title (empty for done and running). Live and reload share it.
|
|
36
|
+
export function statusParts(status) {
|
|
37
|
+
const named = NAMED_STATUSES.includes(status);
|
|
38
|
+
const cls = named ? status : "ok";
|
|
39
|
+
const label = named ? status : "done";
|
|
40
|
+
return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
function
|
|
38
|
-
return
|
|
43
|
+
function statusMarkHtml({ cls, label }) {
|
|
44
|
+
return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function statusWordHtml({ cls, word }) {
|
|
48
|
+
return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
// A live tool row filled from its activity row (status, title, output).
|
|
42
52
|
function fillActivityRow(el, row, sessionId) {
|
|
53
|
+
const parts = statusParts(row.status);
|
|
43
54
|
const statusEl = el.querySelector(".activity-status");
|
|
44
|
-
statusEl.className = `activity-status ${
|
|
45
|
-
statusEl.
|
|
55
|
+
statusEl.className = `activity-status ${parts.cls}`;
|
|
56
|
+
statusEl.setAttribute("role", "img");
|
|
57
|
+
statusEl.setAttribute("aria-label", parts.label);
|
|
58
|
+
statusEl.title = parts.label;
|
|
59
|
+
// The exception's word after the title (reloadRowHtml's place); never
|
|
60
|
+
// beside a running task_wait's stop button, which needs "running".
|
|
61
|
+
let wordEl = el.querySelector(":scope > .activity-state");
|
|
62
|
+
if (!parts.word) wordEl?.remove();
|
|
63
|
+
else {
|
|
64
|
+
if (!wordEl) {
|
|
65
|
+
wordEl = document.createElement("span");
|
|
66
|
+
(el.querySelector(":scope > .activity-params") || el.querySelector(":scope > .activity-tool")).after(wordEl);
|
|
67
|
+
}
|
|
68
|
+
wordEl.className = `activity-state ${parts.cls}`;
|
|
69
|
+
wordEl.textContent = parts.word;
|
|
70
|
+
}
|
|
46
71
|
const toolEl = el.querySelector(".activity-tool");
|
|
47
72
|
if (toolEl) toolEl.textContent = toolName(row);
|
|
48
73
|
const paramsEl = el.querySelector(".activity-params");
|
|
@@ -363,13 +388,21 @@ export function createTurnView({
|
|
|
363
388
|
// answer, which reuses the wrap).
|
|
364
389
|
if (!gen.text && !gen.tools.length && turn.gens.length > 1) unwrapThinking(entry);
|
|
365
390
|
}
|
|
366
|
-
entry
|
|
391
|
+
writeClosedLabel(entry, gen);
|
|
367
392
|
if (!entry.toggled) entry.el.open = false;
|
|
368
393
|
// Every generation's text goes into seenContent (the turn output dedupe).
|
|
369
394
|
const k = normalize(gen.text);
|
|
370
395
|
if (k) sawText(k);
|
|
371
396
|
}
|
|
372
397
|
|
|
398
|
+
// A closed step's label and its failed mark (a red chevron that survives
|
|
399
|
+
// an ellipsed label): at close, and again when a late tool result lands
|
|
400
|
+
// in a step that already closed.
|
|
401
|
+
function writeClosedLabel(entry, gen) {
|
|
402
|
+
entry.summary.textContent = model.genLabel(gen);
|
|
403
|
+
entry.el.classList.toggle("has-failed", model.failedCount(gen.tools) > 0);
|
|
404
|
+
}
|
|
405
|
+
|
|
373
406
|
// A step that closes mid-turn (the next one starts) collapses: a warn card
|
|
374
407
|
// in it would go out of sight until the turn ends. It moves out of the
|
|
375
408
|
// step, right after it in the block (open while the turn runs), where
|
|
@@ -417,6 +450,7 @@ export function createTurnView({
|
|
|
417
450
|
fillActivityRow(el, r.row, sessionId());
|
|
418
451
|
syncTaskStop(el, r.row);
|
|
419
452
|
}
|
|
453
|
+
if (!entry.el.classList.contains("live")) writeClosedLabel(entry, r.gen);
|
|
420
454
|
updateBlockSummary();
|
|
421
455
|
onChange();
|
|
422
456
|
}
|
|
@@ -712,8 +746,9 @@ function reloadRowHtml(r, thumbs) {
|
|
|
712
746
|
const output = full
|
|
713
747
|
? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
|
|
714
748
|
: "";
|
|
715
|
-
|
|
716
|
-
|
|
749
|
+
const status = statusParts(r.status);
|
|
750
|
+
return `<div class="activity-row" data-key="${escapeHtml(r.key)}">${statusMarkHtml(status)}` +
|
|
751
|
+
`<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${statusWordHtml(status)}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
717
752
|
`${commandBlockHtml(r.view, r.title)}${output}` +
|
|
718
753
|
`${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
|
|
719
754
|
}
|
|
@@ -775,7 +810,8 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
775
810
|
// The steers the step answered first, where the live rows went.
|
|
776
811
|
const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
|
|
777
812
|
const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
|
|
778
|
-
|
|
813
|
+
const failed = model.failedCount(gen.tools) ? " has-failed" : "";
|
|
814
|
+
return `<details class="gen${failed}"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
|
|
779
815
|
});
|
|
780
816
|
parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
|
|
781
817
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -515,7 +515,16 @@ module Samagotchi
|
|
|
515
515
|
unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
|
|
516
516
|
SessionInbox.write_output(@session_dir, response)
|
|
517
517
|
end
|
|
518
|
+
save_after_turn
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# The turn's own save: the turn ended fine and its messages are in
|
|
522
|
+
# memory, so a save that fails here (disk full, permissions; the next
|
|
523
|
+
# save writes them) must not take the worker down with it.
|
|
524
|
+
def save_after_turn
|
|
518
525
|
save_session
|
|
526
|
+
rescue SystemCallError, IOError => e
|
|
527
|
+
Log.exception(:worker, "save_failed", e)
|
|
519
528
|
end
|
|
520
529
|
|
|
521
530
|
# Back to the conversation before the failed turn, as the REPL does (so
|