samagotchi 0.9.0 → 0.10.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 +56 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +3 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +34 -12
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
|
@@ -10,6 +10,7 @@ require_relative "image_input"
|
|
|
10
10
|
require_relative "line_reader"
|
|
11
11
|
require_relative "question_prompt"
|
|
12
12
|
require_relative "reline_seam"
|
|
13
|
+
require_relative "../bridge/turn_accumulator"
|
|
13
14
|
require_relative "../bridge_client"
|
|
14
15
|
require_relative "../log"
|
|
15
16
|
require_relative "../context_note"
|
|
@@ -50,9 +51,6 @@ module Samagotchi
|
|
|
50
51
|
JOIN_ANSWER_LINES = 12
|
|
51
52
|
JOIN_ANSWER_CHARS = 1200
|
|
52
53
|
|
|
53
|
-
# Prompt labels by the sender's client_id prefix.
|
|
54
|
-
CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate" }.freeze
|
|
55
|
-
|
|
56
54
|
attr_reader :client_id
|
|
57
55
|
|
|
58
56
|
# @return [String, nil] the model the worker's turns run on, as the
|
|
@@ -199,10 +197,7 @@ module Samagotchi
|
|
|
199
197
|
end_turn
|
|
200
198
|
own_turn_ended(event)
|
|
201
199
|
when :prompt_restored then restore_prompt(event)
|
|
202
|
-
when :context_status
|
|
203
|
-
@status.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
204
|
-
when :used_memories_updated
|
|
205
|
-
@status.update(used_memories: Array(event[:used_memory_names]))
|
|
200
|
+
when :context_status, :used_memories_updated then @status.take_event(event)
|
|
206
201
|
# Another client's anytime command: its line now, before the cards
|
|
207
202
|
# it shows (its command_ran comes when it's done).
|
|
208
203
|
when :command_queued
|
|
@@ -239,7 +234,7 @@ module Samagotchi
|
|
|
239
234
|
line = EventRenderer.init_line(event)
|
|
240
235
|
@screen.commit(line) if line
|
|
241
236
|
when :generation_completed
|
|
242
|
-
|
|
237
|
+
@status.take_event(event)
|
|
243
238
|
@renderer.call(event)
|
|
244
239
|
when :stream_closed
|
|
245
240
|
@view.finish_thinking_spinner
|
|
@@ -968,41 +963,44 @@ module Samagotchi
|
|
|
968
963
|
return unless turn
|
|
969
964
|
|
|
970
965
|
continues = turn[:prompt].nil?
|
|
971
|
-
@renderer.turn_continues = continues
|
|
972
966
|
unless continues && reminder_origin?(turn[:origin] || {})
|
|
973
967
|
@screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
|
|
974
968
|
end
|
|
975
|
-
Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
976
969
|
tail = nil
|
|
977
970
|
lane = :writing
|
|
978
971
|
running_tool = nil
|
|
979
|
-
|
|
980
|
-
case
|
|
981
|
-
when
|
|
982
|
-
|
|
983
|
-
when
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
when
|
|
990
|
-
when
|
|
991
|
-
|
|
992
|
-
|
|
972
|
+
Bridge::TurnAccumulator.replay_events(turn).each do |event|
|
|
973
|
+
case event[:type]
|
|
974
|
+
when :generation_chunk
|
|
975
|
+
tail, lane = event[:thinking].to_s.empty? ? [event[:text], :writing] : [event[:thinking], :thinking]
|
|
976
|
+
when :tool_call_started
|
|
977
|
+
running_tool = event[:tool]
|
|
978
|
+
@renderer.call(event)
|
|
979
|
+
when :tool_call_completed
|
|
980
|
+
running_tool = nil
|
|
981
|
+
replay_tool_completed(event)
|
|
982
|
+
when :merged_input then @screen.commit("input> #{event[:content]}")
|
|
983
|
+
when :reminder_injected then @screen.commit(reminder_line(event[:reminders]))
|
|
984
|
+
# The turn's start (its images), a plugin's steer, and its rows (a
|
|
985
|
+
# hook's notice, a retry's line), as they were drawn live.
|
|
986
|
+
# Questions are left out: the pending one is asked after the
|
|
987
|
+
# replay, answered ones showed no line.
|
|
988
|
+
when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry then @renderer.call(event)
|
|
993
989
|
end
|
|
994
990
|
end
|
|
995
991
|
@view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
|
|
996
992
|
ask(turn[:pending_question]) if turn[:pending_question]
|
|
997
993
|
end
|
|
998
994
|
|
|
999
|
-
#
|
|
1000
|
-
#
|
|
1001
|
-
#
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
995
|
+
# A finished tool call of the joined turn: the live row (action,
|
|
996
|
+
# duration). A snapshot from an older worker has no action: its row
|
|
997
|
+
# names the tool only.
|
|
998
|
+
def replay_tool_completed(event)
|
|
999
|
+
return @renderer.call({ duration_ms: nil }.merge(event)) if event.dig(:activity, :action)
|
|
1000
|
+
|
|
1001
|
+
activity = event[:activity] || {}
|
|
1002
|
+
@screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
|
|
1003
|
+
images: event[:images], diff: event[:diff] }))
|
|
1006
1004
|
end
|
|
1007
1005
|
|
|
1008
1006
|
def show_enqueued(event)
|
|
@@ -1051,22 +1049,7 @@ module Samagotchi
|
|
|
1051
1049
|
# The joining snapshot's session state: the worker's model and the
|
|
1052
1050
|
# memories the session used.
|
|
1053
1051
|
def take_session_state(state)
|
|
1054
|
-
|
|
1055
|
-
fields.merge!(model: state[:model_name], default_model: default_model_name) if state[:model_name]
|
|
1056
|
-
fields[:served] = [state[:served_model], state[:served_model_for]] if state[:served_model]
|
|
1057
|
-
fields[:used_memories] = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
|
|
1058
|
-
fields[:preloaded] = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
|
|
1059
|
-
fields[:muted] = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
|
|
1060
|
-
fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
|
|
1061
|
-
# The last turn's ctx, so it shows before this client's first turn.
|
|
1062
|
-
fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
|
|
1063
|
-
@status.update(**fields)
|
|
1064
|
-
end
|
|
1065
|
-
|
|
1066
|
-
# What the worker's server said it served for a name (a generation);
|
|
1067
|
-
# dropped when /model switches.
|
|
1068
|
-
def take_served_model(served, served_for)
|
|
1069
|
-
@status.update(served: [served, served_for]) if served
|
|
1052
|
+
@status.take_state(state, default_model: (default_model_name if state[:model_name]))
|
|
1070
1053
|
end
|
|
1071
1054
|
|
|
1072
1055
|
# The config's default model, as the worker's /model names it.
|
|
@@ -1077,39 +1060,6 @@ module Samagotchi
|
|
|
1077
1060
|
end
|
|
1078
1061
|
|
|
1079
1062
|
def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
|
|
1080
|
-
|
|
1081
|
-
CONTEXT_NOTE_PREVIEW = 60
|
|
1082
|
-
|
|
1083
|
-
# "note from slack: <first line>", cut to one line.
|
|
1084
|
-
def context_note_line(label, text)
|
|
1085
|
-
lines = text.to_s.strip.split("\n")
|
|
1086
|
-
first = lines.first.to_s
|
|
1087
|
-
if first.length > CONTEXT_NOTE_PREVIEW
|
|
1088
|
-
first = "#{first[0, CONTEXT_NOTE_PREVIEW - 1]}…"
|
|
1089
|
-
elsif lines.size > 1
|
|
1090
|
-
first += " …"
|
|
1091
|
-
end
|
|
1092
|
-
paint("note from #{label || "?"}: #{first}", 2)
|
|
1093
|
-
end
|
|
1094
|
-
|
|
1095
|
-
def reminder_line(reminders)
|
|
1096
|
-
names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
|
|
1097
|
-
names.empty? ? "reminder" : "reminder: #{names.join(", ")}"
|
|
1098
|
-
end
|
|
1099
|
-
|
|
1100
|
-
def prompt_line(client_id, prompt)
|
|
1101
|
-
label = client_id ? CLIENT_LABELS.fetch(client_id.to_s.split(":", 2).first, "user") : "user"
|
|
1102
|
-
"#{paint("#{label}>", 35)} #{prompt}"
|
|
1103
|
-
end
|
|
1104
|
-
|
|
1105
|
-
# A tool call from the snapshot: it has no action text, only the tool.
|
|
1106
|
-
def snapshot_tool_line(part)
|
|
1107
|
-
params = part[:params].to_s.strip
|
|
1108
|
-
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
1109
|
-
status = part[:status].to_s
|
|
1110
|
-
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
|
|
1111
|
-
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1112
|
-
end
|
|
1113
1063
|
end
|
|
1114
1064
|
end
|
|
1115
1065
|
end
|
|
@@ -90,7 +90,7 @@ module Samagotchi
|
|
|
90
90
|
|
|
91
91
|
def generation_feedback_retrying(event)
|
|
92
92
|
@lock.synchronize do
|
|
93
|
-
@retry =
|
|
93
|
+
@retry = format_generation_retry_line(event)
|
|
94
94
|
# The retry streams from the start: wait for its first token again.
|
|
95
95
|
@waiting_since = @clock.call if @thinking
|
|
96
96
|
redraw_status
|
|
@@ -213,15 +213,6 @@ module Samagotchi
|
|
|
213
213
|
@waiting_since = nil
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
-
# "retrying (1/4 in 0.5s): Errno::ECONNREFUSED": the attempt of all
|
|
217
|
-
# there will be, the wait before it, and what failed.
|
|
218
|
-
def retry_text(event)
|
|
219
|
-
return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
|
|
220
|
-
|
|
221
|
-
text = "retrying (#{event[:attempt]}/#{event[:max_retries].to_i + 1} in #{format("%.1f", event[:next_delay].to_f)}s)"
|
|
222
|
-
event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
|
|
223
|
-
end
|
|
224
|
-
|
|
225
216
|
def tally_key(event)
|
|
226
217
|
[event[:iteration].to_i, event[:call_index].to_i]
|
|
227
218
|
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatting"
|
|
4
|
+
require_relative "event_renderer"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# What the REPL prints between turns: cards and plugins' notices shown
|
|
9
|
+
# outside a turn, anytime commands' output, plugins' init lines and load
|
|
10
|
+
# warnings, and a recap written while idle.
|
|
11
|
+
#
|
|
12
|
+
# They are announced on other threads (the Engine's observers, the idle
|
|
13
|
+
# scheduler, an anytime command's own), where they are only kept; the
|
|
14
|
+
# main thread prints them at the open prompt (#flush_recap,
|
|
15
|
+
# #flush_cards), after the command that showed them. One that comes
|
|
16
|
+
# while a turn runs prints at once (above the live region) where the
|
|
17
|
+
# turn wouldn't print it itself.
|
|
18
|
+
class BetweenTurns
|
|
19
|
+
include Formatting
|
|
20
|
+
|
|
21
|
+
# A plugin's init task and a load warning: shown between turns too.
|
|
22
|
+
INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
|
|
23
|
+
|
|
24
|
+
# @param surface [Surface] where lines print (swapped with #surface=)
|
|
25
|
+
# @param view [AttachedView] turns the activity row for init tasks
|
|
26
|
+
# @param renderer [EventRenderer] draws cards
|
|
27
|
+
# @param turn_running [#call] whether a turn runs now
|
|
28
|
+
# @param quiet [Boolean] a --non-interactive run: no init lines
|
|
29
|
+
def initialize(surface:, view:, renderer:, turn_running:, quiet: false)
|
|
30
|
+
@surface = surface
|
|
31
|
+
@view = view
|
|
32
|
+
@renderer = renderer
|
|
33
|
+
@turn_running = turn_running
|
|
34
|
+
@quiet = quiet
|
|
35
|
+
@cards = Queue.new
|
|
36
|
+
@recap = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_writer :surface
|
|
40
|
+
|
|
41
|
+
# The Engine's observer for cards, notices and init events (any
|
|
42
|
+
# thread). A card or a plugin's notice shown outside a turn is kept
|
|
43
|
+
# for the next flush; one shown during a turn is a turn event, the
|
|
44
|
+
# turn's sink prints it where it happens (EventRenderer).
|
|
45
|
+
#
|
|
46
|
+
# An anytime command's (event[:anytime]) print as it shows them: on
|
|
47
|
+
# the main thread (the command runs at the prompt) or beside a running
|
|
48
|
+
# turn, else at the next flush.
|
|
49
|
+
#
|
|
50
|
+
# A plugin's init task (chi.init) turns the activity row while it runs
|
|
51
|
+
# and prints a line when it is done; a load warning announced before
|
|
52
|
+
# the first turn, one. Beside a running turn they print at once.
|
|
53
|
+
def observe(event)
|
|
54
|
+
if INIT_EVENTS.include?(event[:type])
|
|
55
|
+
return if @quiet
|
|
56
|
+
return @view.init_started(event) if event[:type] == :plugin_init_started
|
|
57
|
+
|
|
58
|
+
@view.init_finished(event) if event[:type] == :plugin_init_finished
|
|
59
|
+
return show(event) if turn_running?
|
|
60
|
+
|
|
61
|
+
return @cards << event
|
|
62
|
+
end
|
|
63
|
+
return unless (event[:type] == :card && !event[:in_turn]) || (event[:type] == :hook_notice && event[:between_turns])
|
|
64
|
+
return show(event) if event[:anytime] && (Thread.current == Thread.main || turn_running?)
|
|
65
|
+
|
|
66
|
+
@cards << event
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Keep an item for the next flush (any thread).
|
|
70
|
+
def keep(item)
|
|
71
|
+
@cards << item
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# An anytime command's output: beside a running turn at once, else at
|
|
75
|
+
# the next flush.
|
|
76
|
+
def show_or_keep(item)
|
|
77
|
+
turn_running? ? show(item) : keep(item)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The Engine's observer for :recap_ready (the idle scheduler's thread):
|
|
81
|
+
# kept for #flush_recap. One collected just as a turn started describes
|
|
82
|
+
# the chat before it, and is dropped.
|
|
83
|
+
def take_recap(event)
|
|
84
|
+
return unless event[:type] == :recap_ready
|
|
85
|
+
return if event[:recap].to_s.strip.empty? || turn_running?
|
|
86
|
+
|
|
87
|
+
@recap = event[:recap].to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Print a recap written while idle (main thread, at the open prompt).
|
|
91
|
+
def flush_recap
|
|
92
|
+
recap = @recap
|
|
93
|
+
return unless recap
|
|
94
|
+
|
|
95
|
+
@recap = nil
|
|
96
|
+
@surface.commit(recap_block(recap))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Print the cards, notices and anytime commands' output kept since the
|
|
100
|
+
# last flush (main thread). A card replaced later in the same batch
|
|
101
|
+
# (the same id: btw's "thinking…", then its answer) prints once, as its
|
|
102
|
+
# last.
|
|
103
|
+
def flush_cards
|
|
104
|
+
items = []
|
|
105
|
+
loop { items << @cards.pop(true) }
|
|
106
|
+
rescue ThreadError
|
|
107
|
+
items.each_with_index do |item, index|
|
|
108
|
+
replaced = item[:type] == :card && items.drop(index + 1).any? { |later| later[:type] == :card && later[:id] == item[:id] }
|
|
109
|
+
show(item) unless replaced
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A card, a notice, an anytime command's output (:command_output), a
|
|
115
|
+
# plugin init task's done line or a load warning.
|
|
116
|
+
def show(item)
|
|
117
|
+
case item[:type]
|
|
118
|
+
when :card then @renderer.render_card(item)
|
|
119
|
+
when :command_output then @surface.commit(item[:text])
|
|
120
|
+
when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
|
|
121
|
+
when :plugin_init_finished
|
|
122
|
+
line = EventRenderer.init_line(item)
|
|
123
|
+
@surface.commit(line) if line
|
|
124
|
+
else @surface.commit(EventRenderer.hook_notice_line(item))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def turn_running? = @turn_running.call
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -68,7 +68,9 @@ module Samagotchi
|
|
|
68
68
|
when :tool_call_completed
|
|
69
69
|
@view.clear_generation_retry
|
|
70
70
|
@view.tool_call_feedback_completed(event)
|
|
71
|
-
|
|
71
|
+
# A replayed row (a join) carries its duration (nil: unknown).
|
|
72
|
+
measured = tool_duration_ms(event)
|
|
73
|
+
render_streamed_tool_activity(event[:activity], duration_ms: event.fetch(:duration_ms, measured), images: event[:images],
|
|
72
74
|
diff: event[:diff])
|
|
73
75
|
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
74
76
|
@view.generation_feedback_finished
|
|
@@ -44,6 +44,16 @@ module Samagotchi
|
|
|
44
44
|
paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# "retrying (1/3 in 0.5s): Errno::ECONNREFUSED": the retry of all the
|
|
48
|
+
# retries there will be (as the web counts), the wait before it, and
|
|
49
|
+
# what failed (generation_retrying).
|
|
50
|
+
def format_generation_retry_line(event)
|
|
51
|
+
return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
|
|
52
|
+
|
|
53
|
+
text = "retrying (#{event[:attempt]}/#{event[:max_retries]} in #{format("%.1f", event[:next_delay].to_f)}s)"
|
|
54
|
+
event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
# " → image 1280×800" after a tool line whose tool read an image.
|
|
48
58
|
def format_tool_image_suffix(images)
|
|
49
59
|
refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
|
|
@@ -61,6 +71,45 @@ module Samagotchi
|
|
|
61
71
|
" #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
|
|
62
72
|
end
|
|
63
73
|
|
|
74
|
+
# Prompt labels by the sender's client_id prefix (turn_events.js
|
|
75
|
+
# CLIENT_LABELS; spec/shared/labels_matrix.json).
|
|
76
|
+
CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate" }.freeze
|
|
77
|
+
|
|
78
|
+
# "web> <prompt>": a prompt, labelled by who sent it.
|
|
79
|
+
def prompt_line(client_id, prompt)
|
|
80
|
+
label = client_id ? CLIENT_LABELS.fetch(client_id.to_s.split(":", 2).first, "user") : "user"
|
|
81
|
+
"#{paint("#{label}>", 35)} #{prompt}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# "reminder: a, b": the reminders a turn runs for (names or hashes).
|
|
85
|
+
def reminder_line(reminders)
|
|
86
|
+
names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
|
|
87
|
+
names.empty? ? "reminder" : "reminder: #{names.join(", ")}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
CONTEXT_NOTE_PREVIEW = 60
|
|
91
|
+
|
|
92
|
+
# "note from slack: <first line>", cut to one line.
|
|
93
|
+
def context_note_line(label, text)
|
|
94
|
+
lines = text.to_s.strip.split("\n")
|
|
95
|
+
first = lines.first.to_s
|
|
96
|
+
if first.length > CONTEXT_NOTE_PREVIEW
|
|
97
|
+
first = "#{first[0, CONTEXT_NOTE_PREVIEW - 1]}\u2026"
|
|
98
|
+
elsif lines.size > 1
|
|
99
|
+
first += " \u2026"
|
|
100
|
+
end
|
|
101
|
+
paint("note from #{label || "?"}: #{first}", 2)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A tool call from the snapshot: it has no action text, only the tool.
|
|
105
|
+
def snapshot_tool_line(part)
|
|
106
|
+
params = part[:params].to_s.strip
|
|
107
|
+
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
108
|
+
status = part[:status].to_s
|
|
109
|
+
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
|
|
110
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
111
|
+
end
|
|
112
|
+
|
|
64
113
|
# A card (Engine#show_card) as a framed block: the title and its
|
|
65
114
|
# source, the body as wrapped plain text (no terminal markdown), and
|
|
66
115
|
# one `→ <command>` line per action, the label after it when it says
|
|
@@ -140,7 +189,8 @@ module Samagotchi
|
|
|
140
189
|
# ── Turn ends: one set of words for the REPL, attached mode and the
|
|
141
190
|
# web (timing.js cancelLineText, format.js failedTurnText) ──────────
|
|
142
191
|
|
|
143
|
-
# A cancel's reason as the UIs name it (anything else as it is)
|
|
192
|
+
# A cancel's reason as the UIs name it (anything else as it is);
|
|
193
|
+
# spec/shared/labels_matrix.json pins it for both.
|
|
144
194
|
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
|
|
145
195
|
# Under a canceled prompt turn (a canceled continue is back where it
|
|
146
196
|
# started): its partial progress stays in the conversation.
|
|
@@ -52,6 +52,38 @@ module Samagotchi
|
|
|
52
52
|
refresh
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Take a session's state: the REPL's Engine#session_state_snapshot, or
|
|
56
|
+
# the one a worker sends an attached TUI when it joins. A key the state
|
|
57
|
+
# lacks (an older worker's) leaves its field as it was; the served pair
|
|
58
|
+
# is cleared without one, and the model is kept without a name.
|
|
59
|
+
# @param default_model [String, nil] the config's default, named beside
|
|
60
|
+
# another model
|
|
61
|
+
def take_state(state, default_model: nil)
|
|
62
|
+
fields = { served: state[:served_model] ? [state[:served_model], state[:served_model_for]] : nil }
|
|
63
|
+
fields.merge!(model: state[:model_name], default_model: default_model) if state[:model_name]
|
|
64
|
+
STATE_LISTS.each { |field, key| fields[field] = Array(state[key]) if state.key?(key) }
|
|
65
|
+
fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
|
|
66
|
+
# The last turn's ctx, so it shows before the next turn's.
|
|
67
|
+
fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
|
|
68
|
+
update(**fields)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The memory lists' fields and the state keys they come from.
|
|
72
|
+
STATE_LISTS = { used_memories: :used_memory_names, preloaded: :preloaded_memory_names,
|
|
73
|
+
muted: :muted_memory_names }.freeze
|
|
74
|
+
|
|
75
|
+
# Take what a turn event says about the row (the Engine's events in
|
|
76
|
+
# the REPL, the Bridge's in attached mode): ctx, the memories used,
|
|
77
|
+
# the model the server served. Other events change nothing.
|
|
78
|
+
def take_event(event)
|
|
79
|
+
case event[:type]
|
|
80
|
+
when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
81
|
+
when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
|
|
82
|
+
when :generation_completed
|
|
83
|
+
update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
55
87
|
# Draw the row unless it shows that already (status.line: off draws
|
|
56
88
|
# nothing).
|
|
57
89
|
def refresh
|
|
@@ -11,9 +11,9 @@ module Samagotchi
|
|
|
11
11
|
# ThinkingTicker (so it changes at most once per dwell). Both TUIs use it
|
|
12
12
|
# in place of the raw tail of the stream.
|
|
13
13
|
#
|
|
14
|
-
# A chunk carries :text (and :thinking)
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# A chunk carries :text (and :thinking): both loops split their stream,
|
|
15
|
+
# for every profile. A chunk without them (an older worker) has its raw
|
|
16
|
+
# :content, markup stripped, stand for thinking. A switch of lane starts the next sentence from the new
|
|
17
17
|
# lane's text; the line shown keeps the label of the lane it came from.
|
|
18
18
|
class ThinkingLine
|
|
19
19
|
include Formatting
|