samagotchi 0.14.0 → 0.15.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 +28 -1
- data/docs/plugins.md +3 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +7 -4
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/plugin_session_state.rb +30 -0
- data/lib/samagotchi/self_report.rb +24 -4
- data/lib/samagotchi/session_manager.rb +2 -0
- data/lib/samagotchi/session_metrics.rb +1 -1
- data/lib/samagotchi/session_retention.rb +6 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +28 -3
- data/lib/samagotchi/terminal_ui.rb +11 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -0
- data/lib/samagotchi/web/public/app.js +8 -5
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/sessions_list.js +8 -0
- data/lib/samagotchi/web/public/strip.js +8 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7cb85a0b5509a00cd396dd2db8c269c7959ccbbdd004649d626476067b652bbd
|
|
4
|
+
data.tar.gz: b2a46cc3632563de6f1d7c0f979b9ac54bccdfa765a643c576a5c24d7b492bf3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '07187883e2ffb8d0a0cbeaafd870551df02e166d7301e3696223efeba5d7b4eaab002a4ccc5180dd88c8d20c8cbcec302b2c9932ea99bd0b872b34051375d651'
|
|
7
|
+
data.tar.gz: 759493ed3abbd40da61128bca6cbb2805937697af546739b52e1e365b01867d1c648484aefdd7a4b1512acc50515d2c588f6fc48342c36f238b2c34713827750
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,32 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.15.0] - 2026-10-02
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The hidden session strip's pill counts the sessions that wait for you (`▾ 5 sessions · 1 live · 1 waiting`, in the
|
|
16
|
+
warn colour).
|
|
17
|
+
- The all-sessions search finds the sessions that wait for you: `waiting`, `question`, `approval` or `needs you`.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `chi --resume ID` (and `--attach`) shows the last exchange's tool calls as the live tool rows
|
|
22
|
+
(`tool> running command (execute command="true"): ok`) between the prompt and the answer, not the prompt and the
|
|
23
|
+
answer alone.
|
|
24
|
+
- A `--no-shared` REPL keeps the session's `cards.json` as a worker does: its turns count, so the cards and notices an
|
|
25
|
+
earlier worker showed stay where they were in the web, and the REPL's own are saved too.
|
|
26
|
+
- A deleted, discarded or retention-pruned session takes its plugins' state with it: a bundle's
|
|
27
|
+
`plugins/<bundle>/sessions/<id>.json` (or `<id>/`) under the state dir, such as check-in's `/checkin` settings, no
|
|
28
|
+
longer stays behind.
|
|
29
|
+
- `chi self`'s context window row shows the current model's configured window: its `models.<key>.window_tokens`, else
|
|
30
|
+
its host's `hosts.<name>.window_tokens`, else `context.window_tokens` or the default, and says which.
|
|
31
|
+
- The web pairs each prompt with its own turn's timing: a failed turn (its prompt went back to you) no longer shifts
|
|
32
|
+
the timing lines and turn numbers of the turns after it. chi saves a turn id on each prompt; prompts saved before
|
|
33
|
+
this still pair by their place.
|
|
34
|
+
|
|
35
|
+
Update with `chi update` (no bundle changes).
|
|
36
|
+
|
|
11
37
|
## [0.14.0] - 2026-10-02
|
|
12
38
|
|
|
13
39
|
### Added
|
|
@@ -782,7 +808,8 @@ and long-lived sessions.
|
|
|
782
808
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
783
809
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
784
810
|
|
|
785
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
811
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.15.0...HEAD
|
|
812
|
+
[0.15.0]: https://github.com/dm1try/samagotchi/compare/v0.14.0...v0.15.0
|
|
786
813
|
[0.14.0]: https://github.com/dm1try/samagotchi/compare/v0.13.0...v0.14.0
|
|
787
814
|
[0.13.0]: https://github.com/dm1try/samagotchi/compare/v0.12.0...v0.13.0
|
|
788
815
|
[0.12.0]: https://github.com/dm1try/samagotchi/compare/v0.11.0...v0.12.0
|
data/docs/plugins.md
CHANGED
|
@@ -479,7 +479,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
479
479
|
| `ctx.cwd` | the session's working directory |
|
|
480
480
|
| `ctx.repo_root` | the git checkout holding `cwd`, or nil |
|
|
481
481
|
| `ctx.settings` | the bundle's settings, frozen |
|
|
482
|
-
| `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use |
|
|
482
|
+
| `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
|
|
483
483
|
| `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
|
|
484
484
|
| `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
|
|
485
485
|
| `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
|
|
@@ -937,8 +937,8 @@ with a line and carry on unchanged.
|
|
|
937
937
|
|
|
938
938
|
`/checkin` (anytime) for this session. What it changes (on/off, the mode,
|
|
939
939
|
the threshold) is saved with the session, in the bundle's data dir
|
|
940
|
-
(`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart
|
|
941
|
-
another session starts from config.yml:
|
|
940
|
+
(`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart
|
|
941
|
+
and goes with the session; another session starts from config.yml:
|
|
942
942
|
|
|
943
943
|
| | |
|
|
944
944
|
|---|---|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1362,8 +1362,10 @@ module Samagotchi
|
|
|
1362
1362
|
# +ended+: the end event is out (what follows is post-turn work).
|
|
1363
1363
|
# +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
|
|
1364
1364
|
# set on the kernel in #generate.
|
|
1365
|
+
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1366
|
+
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1365
1367
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1366
|
-
:settings) do
|
|
1368
|
+
:settings, :id) do
|
|
1367
1369
|
# The turn's boundary events carry the origin only when there is one,
|
|
1368
1370
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1369
1371
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1478,7 +1480,8 @@ module Samagotchi
|
|
|
1478
1480
|
@guardrail_wiring.begin_turn(origin)
|
|
1479
1481
|
|
|
1480
1482
|
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
1481
|
-
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1483
|
+
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
1484
|
+
id: SecureRandom.uuid)
|
|
1482
1485
|
end
|
|
1483
1486
|
private :begin_turn
|
|
1484
1487
|
|
|
@@ -1492,7 +1495,7 @@ module Samagotchi
|
|
|
1492
1495
|
image_refs, image_error = turn_image_refs(session, images)
|
|
1493
1496
|
# Emit turn_started event
|
|
1494
1497
|
bind_metrics(session)
|
|
1495
|
-
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
|
|
1498
|
+
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
1496
1499
|
turn_started[:continue] = true if turn.continue
|
|
1497
1500
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1498
1501
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
@@ -1595,7 +1598,7 @@ module Samagotchi
|
|
|
1595
1598
|
|
|
1596
1599
|
return if turn.continue
|
|
1597
1600
|
|
|
1598
|
-
user_message = { role: "user", content: turn.prompt }
|
|
1601
|
+
user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
|
|
1599
1602
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
1600
1603
|
turn.messages << user_message
|
|
1601
1604
|
session.last_prompt = turn.prompt
|
|
@@ -193,9 +193,10 @@ module Samagotchi
|
|
|
193
193
|
# The shape the engine persists: role and content (parts stay an
|
|
194
194
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
195
195
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
196
|
-
# refs of a user message or a tool result,
|
|
197
|
-
#
|
|
198
|
-
# never sent back), and an
|
|
196
|
+
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
197
|
+
# never sent), and a plugin tool result's tool_params and tool_labels
|
|
198
|
+
# (the live row's params line and label, never sent back), and an
|
|
199
|
+
# edit/write result's tool_diffs (never sent).
|
|
199
200
|
def plain(conversation)
|
|
200
201
|
conversation.map do |entry|
|
|
201
202
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -203,6 +204,7 @@ module Samagotchi
|
|
|
203
204
|
message[:tool_calls] = entry[:tool_calls] if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
|
|
204
205
|
message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
|
|
205
206
|
message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
|
|
207
|
+
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
206
208
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
207
209
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
208
210
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "paths"
|
|
5
|
+
require_relative "session"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# A plugin's state for one session, by convention under its data dir
|
|
9
|
+
# (ctx.data_dir): sessions/<id>.json or sessions/<id>/. It goes when the
|
|
10
|
+
# session does (SessionManager.delete_session: the CLI, the web, a
|
|
11
|
+
# discarded empty session; SessionRetention's prune), so no bundle keeps
|
|
12
|
+
# state for sessions that are gone (check-in's /checkin settings).
|
|
13
|
+
module PluginSessionState
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @return [Array<String>] the paths removed
|
|
17
|
+
def remove(session_id, env: ENV)
|
|
18
|
+
id = session_id.to_s
|
|
19
|
+
# Only a plain id: anything else could name a path outside the dir.
|
|
20
|
+
return [] unless Session.valid_id?(id)
|
|
21
|
+
|
|
22
|
+
Dir.glob(File.join(Paths.state_dir(env: env), "plugins", "*", "sessions")).flat_map do |dir|
|
|
23
|
+
[File.join(dir, "#{id}.json"), File.join(dir, id)].select { |path| File.exist?(path) }
|
|
24
|
+
.each { |path| FileUtils.rm_rf(path) }
|
|
25
|
+
end
|
|
26
|
+
rescue SystemCallError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -56,7 +56,7 @@ module Samagotchi
|
|
|
56
56
|
["profile", model ? profile_for(model, env) : "-"],
|
|
57
57
|
["thinking", model ? thinking_for(model, env) : "-"],
|
|
58
58
|
["served model", model ? served_model_for(model, env) : "-"],
|
|
59
|
-
["context window", context_window(env)],
|
|
59
|
+
["context window", context_window(env, model)],
|
|
60
60
|
["bundles", bundles_summary],
|
|
61
61
|
["desktop", desktop_summary(env)],
|
|
62
62
|
["chi web", web_summary]
|
|
@@ -100,9 +100,29 @@ module Samagotchi
|
|
|
100
100
|
def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
|
|
101
101
|
|
|
102
102
|
# Offline, so only the fallback: the running server's n_ctx wins at runtime.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
# The window config gives the current model (ContextWindow: its
|
|
104
|
+
# models.<key>.window_tokens, its host's, then context.window_tokens or
|
|
105
|
+
# the default) and which of them, as a turn resolves it offline.
|
|
106
|
+
def context_window(env, model = nil)
|
|
107
|
+
tokens, where = model_window(env, model) || ContextWindow.configured(env: env).to_h.values_at(:tokens, :source)
|
|
108
|
+
"#{tokens} (#{where}; the server's n_ctx wins at runtime)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# [tokens, "models: KEY" | "hosts.NAME"], or nil when neither is set.
|
|
112
|
+
def model_window(env, model)
|
|
113
|
+
return nil unless model
|
|
114
|
+
|
|
115
|
+
registry = HostRegistry.new(env: env)
|
|
116
|
+
target = registry.resolve(model)
|
|
117
|
+
names = registry.lookup_names(model, target: target)
|
|
118
|
+
models = ConfigFile.model_settings(env: env)
|
|
119
|
+
window = ContextWindow.setting(target, names: names, models: models)
|
|
120
|
+
return nil unless window
|
|
121
|
+
return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
|
|
122
|
+
|
|
123
|
+
[window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).first}"]
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
def model_name
|
|
@@ -23,6 +23,7 @@ require_relative "installed_gem"
|
|
|
23
23
|
require_relative "recap_store"
|
|
24
24
|
require_relative "archive_store"
|
|
25
25
|
require_relative "image_store"
|
|
26
|
+
require_relative "plugin_session_state"
|
|
26
27
|
require_relative "terminal_ui"
|
|
27
28
|
|
|
28
29
|
module Samagotchi
|
|
@@ -598,6 +599,7 @@ module Samagotchi
|
|
|
598
599
|
FileUtils.rm_rf(dir)
|
|
599
600
|
removed << dir
|
|
600
601
|
end
|
|
602
|
+
PluginSessionState.remove(id)
|
|
601
603
|
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
602
604
|
end
|
|
603
605
|
|
|
@@ -8,6 +8,7 @@ require_relative "config"
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
require_relative "model_profile"
|
|
10
10
|
require_relative "session_manager"
|
|
11
|
+
require_relative "plugin_session_state"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
|
|
@@ -79,7 +80,10 @@ module Samagotchi
|
|
|
79
80
|
next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
|
|
80
81
|
next if SessionManager.session_owner(name, state_dir: state_dir)
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
unless dry_run
|
|
84
|
+
FileUtils.rm_rf(dir)
|
|
85
|
+
PluginSessionState.remove(name)
|
|
86
|
+
end
|
|
83
87
|
name
|
|
84
88
|
rescue SystemCallError
|
|
85
89
|
nil
|
|
@@ -246,6 +250,7 @@ module Samagotchi
|
|
|
246
250
|
FileUtils.rm_f(path)
|
|
247
251
|
sidecar = File.join(state_dir, session.id)
|
|
248
252
|
FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
|
|
253
|
+
PluginSessionState.remove(session.id)
|
|
249
254
|
deleted << session.id
|
|
250
255
|
rescue StandardError
|
|
251
256
|
skipped << session.id
|
|
@@ -21,6 +21,8 @@ require_relative "../output_formatter"
|
|
|
21
21
|
require_relative "../session_commands"
|
|
22
22
|
require_relative "../session_manager"
|
|
23
23
|
require_relative "../guardrails/parent_approvals"
|
|
24
|
+
require_relative "../tool_activity"
|
|
25
|
+
require_relative "../web/message_parts"
|
|
24
26
|
|
|
25
27
|
module Samagotchi
|
|
26
28
|
class TerminalUI
|
|
@@ -937,9 +939,7 @@ module Samagotchi
|
|
|
937
939
|
|
|
938
940
|
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
939
941
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
940
|
-
|
|
941
|
-
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
942
|
-
end
|
|
942
|
+
render_join_steps(messages, messages.index { |m| m.equal?(exchange[last_user]) })
|
|
943
943
|
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
944
944
|
# be only a tool call.
|
|
945
945
|
answer = exchange[(last_user + 1)..].reverse_each
|
|
@@ -949,6 +949,31 @@ module Samagotchi
|
|
|
949
949
|
render_join_notes(messages, after_exchange: true)
|
|
950
950
|
end
|
|
951
951
|
|
|
952
|
+
# What the last exchange did, as the live turn drew it: each saved
|
|
953
|
+
# call's tool row (action, params, status from its result; no
|
|
954
|
+
# duration, which the session doesn't keep) and a plugin's steer
|
|
955
|
+
# where it came. The step texts stay out, as live (the ticker's).
|
|
956
|
+
def render_join_steps(messages, from)
|
|
957
|
+
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
958
|
+
if Steer.steer?(m)
|
|
959
|
+
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
960
|
+
elsif %w[model assistant].include?(m[:role].to_s)
|
|
961
|
+
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
962
|
+
Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each { |part| @screen.commit(join_tool_line(part)) }
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# A saved call's row; "no result" for a call the turn never answered
|
|
968
|
+
# (it ended first).
|
|
969
|
+
def join_tool_line(part)
|
|
970
|
+
output = part[:output]
|
|
971
|
+
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
|
|
972
|
+
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
973
|
+
params: part[:params], status: status }
|
|
974
|
+
"#{format_tool_activity_line(activity)}#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
975
|
+
end
|
|
976
|
+
|
|
952
977
|
# The context notes that came since the last prompt (all of them in a
|
|
953
978
|
# session with none), after the exchange the header shows.
|
|
954
979
|
def render_join_notes(messages, after_exchange: false)
|
|
@@ -14,6 +14,7 @@ require_relative "llm/errors"
|
|
|
14
14
|
require_relative "host_registry"
|
|
15
15
|
require_relative "session"
|
|
16
16
|
require_relative "owner_lock"
|
|
17
|
+
require_relative "bridge/card_store"
|
|
17
18
|
require_relative "engine"
|
|
18
19
|
require_relative "guardrails/parent_approvals"
|
|
19
20
|
require_relative "tools/memory"
|
|
@@ -223,6 +224,7 @@ module Samagotchi
|
|
|
223
224
|
scratch: @scratch
|
|
224
225
|
))
|
|
225
226
|
claim_session!(session.id) unless @owner_lock
|
|
227
|
+
keep_cards(session)
|
|
226
228
|
# Saved at once, so a process killed before its first turn leaves a
|
|
227
229
|
# file the sweep knows to delete.
|
|
228
230
|
session.save if @scratch
|
|
@@ -345,6 +347,15 @@ module Samagotchi
|
|
|
345
347
|
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
346
348
|
end
|
|
347
349
|
|
|
350
|
+
# The session's cards.json, kept as a worker's Bridge keeps it (it owns
|
|
351
|
+
# the session as a worker would): this REPL's kept turns count on from
|
|
352
|
+
# the saved count, so a card an earlier worker showed stays that many
|
|
353
|
+
# turns back, and its own cards and notices are saved for the next.
|
|
354
|
+
def keep_cards(session)
|
|
355
|
+
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
356
|
+
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
|
|
357
|
+
end
|
|
358
|
+
|
|
348
359
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
349
360
|
# its own Engine, so no worker may run the session meanwhile.
|
|
350
361
|
# @raise [SessionBusy] when a worker or another TUI owns it
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1269,6 +1269,10 @@ module Samagotchi
|
|
|
1269
1269
|
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
1270
1270
|
# read by step +step+.
|
|
1271
1271
|
message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
1272
|
+
# The turn it started (Engine): the page pairs it with that turn's
|
|
1273
|
+
# record by this, not by its place.
|
|
1274
|
+
turn_id = m[:turn_id] || m["turn_id"]
|
|
1275
|
+
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
1272
1276
|
images = m[:images] || m["images"]
|
|
1273
1277
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
1274
1278
|
if norm_role == "assistant"
|
|
@@ -38,7 +38,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
|
38
38
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
39
39
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
40
40
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
41
|
-
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, withChildrenAfterParents } from "./sessions_list.js";
|
|
41
|
+
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
42
42
|
import { createNotifications } from "./notifications.js";
|
|
43
43
|
import {
|
|
44
44
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
@@ -262,7 +262,8 @@ function filteredSessions(list) {
|
|
|
262
262
|
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
263
263
|
s.short_id.toLowerCase().includes(q) ||
|
|
264
264
|
s.id.toLowerCase().includes(q) ||
|
|
265
|
-
s.status.toLowerCase().includes(q)
|
|
265
|
+
s.status.toLowerCase().includes(q) ||
|
|
266
|
+
waitingSearchText(s).includes(q),
|
|
266
267
|
);
|
|
267
268
|
}
|
|
268
269
|
|
|
@@ -385,11 +386,13 @@ function renderTop(sessions) {
|
|
|
385
386
|
$("#stripHide").addEventListener("click", () => setStripHidden(true));
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
// The hidden strip's pill: "▾ 5 sessions · ● 1 live
|
|
389
|
+
// The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
|
|
390
|
+
// following the list.
|
|
389
391
|
function renderStripShow() {
|
|
390
|
-
const { count, live } = stripShowParts(shownSessions());
|
|
392
|
+
const { count, live, waiting } = stripShowParts(shownSessions());
|
|
391
393
|
const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
|
|
392
|
-
|
|
394
|
+
const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
|
|
395
|
+
$("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
|
|
393
396
|
}
|
|
394
397
|
|
|
395
398
|
// A delegated session sits right after its parent: applied on every
|
|
@@ -636,6 +636,8 @@ body.has-session #scope .scope-where{display:none}
|
|
|
636
636
|
#stripShow{display:none;white-space:nowrap;flex-shrink:0}
|
|
637
637
|
#stripShow .live{color:var(--ok-text);white-space:nowrap}
|
|
638
638
|
#stripShow .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
|
|
639
|
+
#stripShow .waiting{color:var(--warn-text);white-space:nowrap}
|
|
640
|
+
#stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
|
|
639
641
|
#stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
|
|
640
642
|
#stripHide:hover{text-decoration:underline;color:var(--text-2)}
|
|
641
643
|
body.strip-hidden #stripShow{display:block}
|
|
@@ -81,6 +81,14 @@ export function waitingBadge(summary) {
|
|
|
81
81
|
return waiting ? { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] } : null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// What the all view's search matches for a session that waits on the user:
|
|
85
|
+
// "waiting" and its badge's word ("question", "approval", "needs you");
|
|
86
|
+
// "" for one that doesn't.
|
|
87
|
+
export function waitingSearchText(summary) {
|
|
88
|
+
const badge = waitingBadge(summary);
|
|
89
|
+
return badge ? `waiting ${badge.text}` : "";
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
// The sessions that wait on the user (waitingOn) first, the rest after,
|
|
85
93
|
// each part in the list's order: an answered one goes back to its place.
|
|
86
94
|
// A delegated session moves with its parent when both are listed (a
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { waitingOn } from "./notify.js";
|
|
2
|
+
|
|
1
3
|
// How many session cards the top strip shows for its width in px: a card
|
|
2
4
|
// needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
|
|
3
5
|
// never fewer than 3 (narrow screens scroll them), never more than 6.
|
|
@@ -13,13 +15,18 @@ export function stripColumns(width) {
|
|
|
13
15
|
// The hidden strip's "▾ sessions" pill carries the list's count, and how
|
|
14
16
|
// many of those sessions a worker runs (the cards' green "live" badge), so a
|
|
15
17
|
// user who hid the strip still sees there is a live session to go back to.
|
|
16
|
-
//
|
|
18
|
+
// And how many wait on the user (notify.js waitingOn: a question, an
|
|
19
|
+
// approval, a card), so a hidden strip still says one needs an answer.
|
|
20
|
+
// Plain text parts; live and waiting are "" when none. Zero sessions keep
|
|
21
|
+
// the bare label.
|
|
17
22
|
export function stripShowParts(sessions) {
|
|
18
23
|
const list = sessions || [];
|
|
19
24
|
const n = list.length;
|
|
20
25
|
const liveN = list.filter((s) => s && s.owner === "worker").length;
|
|
26
|
+
const waitingN = list.filter((s) => waitingOn(s)).length;
|
|
21
27
|
return {
|
|
22
28
|
count: n ? `${n} ${n === 1 ? "session" : "sessions"}` : "sessions",
|
|
23
29
|
live: liveN ? `${liveN} live` : "",
|
|
30
|
+
waiting: waitingN ? `${waitingN} waiting` : "",
|
|
24
31
|
};
|
|
25
32
|
}
|
|
@@ -105,7 +105,7 @@ export function turnGroups(items, timing) {
|
|
|
105
105
|
turn.merged.push(i);
|
|
106
106
|
} else if (item?.role === "user") {
|
|
107
107
|
turnIndex += 1;
|
|
108
|
-
turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [], merged: [] };
|
|
108
|
+
turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
|
|
109
109
|
groups.push(turn);
|
|
110
110
|
} else if (item?.role === "assistant") {
|
|
111
111
|
if (turn) turn.texts.push(i);
|
|
@@ -138,8 +138,17 @@ function withSteers(group, steerIndexes, items) {
|
|
|
138
138
|
return { ...group, steps, steers: left };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// A prompt's turn record: the one with its turn id (the engine saves it
|
|
142
|
+
// on the prompt), so a turn whose prompt left the conversation (a failed
|
|
143
|
+
// one's went back to the user, !rollback) shifts no other; a prompt saved
|
|
144
|
+
// before turn ids pairs by its place among the prompts.
|
|
145
|
+
function promptRecord(timing, g) {
|
|
146
|
+
if (typeof g.turnId !== "string" || !g.turnId) return turnRecordAt(timing, g.turnIndex);
|
|
147
|
+
return (timing?.turnRecords || []).find((r) => r.id === g.turnId) || null;
|
|
148
|
+
}
|
|
149
|
+
|
|
141
150
|
function turnGroup(g, items, timing) {
|
|
142
|
-
const record =
|
|
151
|
+
const record = promptRecord(timing, g);
|
|
143
152
|
const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
|
|
144
153
|
const stepsOnly = canceledSteps(g, record, tools);
|
|
145
154
|
if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
|
|
@@ -687,8 +687,11 @@ function reloadRowHtml(r, thumbs) {
|
|
|
687
687
|
export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
688
688
|
const timingHtml = (group) => {
|
|
689
689
|
const record = group.record;
|
|
690
|
+
// Numbered by its place in the turn records, as live (a failed turn
|
|
691
|
+
// whose prompt went back still counts).
|
|
692
|
+
const at = (timing?.turnRecords || []).indexOf(record);
|
|
690
693
|
return record && formatDuration(record.duration_ms)
|
|
691
|
-
? `<div class="turn-timing">${escapeHtml(turnTimingText(group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
|
|
694
|
+
? `<div class="turn-timing">${escapeHtml(turnTimingText(at >= 0 ? at + 1 : group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
|
|
692
695
|
: "";
|
|
693
696
|
};
|
|
694
697
|
// The timing line after the bubble, not in it: where the live view
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -301,6 +301,7 @@ files:
|
|
|
301
301
|
- lib/samagotchi/plugin/sessions.rb
|
|
302
302
|
- lib/samagotchi/plugin/side_question.rb
|
|
303
303
|
- lib/samagotchi/plugin/tool_result.rb
|
|
304
|
+
- lib/samagotchi/plugin_session_state.rb
|
|
304
305
|
- lib/samagotchi/plugin_tasks.rb
|
|
305
306
|
- lib/samagotchi/project_scope.rb
|
|
306
307
|
- lib/samagotchi/prompt.rb
|