samagotchi 0.3.0 → 0.5.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 +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -4,6 +4,7 @@ require "set"
|
|
|
4
4
|
require_relative "event_renderer"
|
|
5
5
|
require_relative "formatting"
|
|
6
6
|
require_relative "attached_view"
|
|
7
|
+
require_relative "status_row"
|
|
7
8
|
require_relative "input_support"
|
|
8
9
|
require_relative "image_input"
|
|
9
10
|
require_relative "line_reader"
|
|
@@ -31,19 +32,9 @@ module Samagotchi
|
|
|
31
32
|
include Formatting
|
|
32
33
|
include InputSupport
|
|
33
34
|
|
|
34
|
-
STATS_COMMAND = "/stats"
|
|
35
|
-
RECAP_COMMAND = "/recap"
|
|
36
35
|
PROMPT = "> "
|
|
37
|
-
# Detach and leave the worker up.
|
|
38
|
-
DETACH_COMMANDS = %w[/detach].freeze
|
|
39
|
-
# Detach and ask the worker to exit (bare `exit` too, as in the REPL).
|
|
40
|
-
EXIT_COMMANDS = %w[/exit /quit].freeze
|
|
41
|
-
# After an exit command: delete the session once the worker has gone.
|
|
42
|
-
DELETE_FLAG = "--delete"
|
|
43
36
|
# How long /exit --delete waits for the worker to let go.
|
|
44
37
|
DELETE_WAIT = 10
|
|
45
|
-
# Leave, and archive the session (hidden from the lists, kept for good).
|
|
46
|
-
ARCHIVE_COMMAND = "/archive"
|
|
47
38
|
# A second Ctrl-C at an empty idle prompt within this many seconds detaches.
|
|
48
39
|
DETACH_WINDOW = 2.0
|
|
49
40
|
DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
|
|
@@ -55,7 +46,6 @@ module Samagotchi
|
|
|
55
46
|
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
56
47
|
"reminders" => "reminders are set", "starting" => "the worker is still starting"
|
|
57
48
|
}.freeze
|
|
58
|
-
ROLLBACK_HINT = "partial progress kept in context; !rollback restores the pre-turn state"
|
|
59
49
|
# How much of the last answer a join shows.
|
|
60
50
|
JOIN_ANSWER_LINES = 12
|
|
61
51
|
JOIN_ANSWER_CHARS = 1200
|
|
@@ -67,7 +57,8 @@ module Samagotchi
|
|
|
67
57
|
|
|
68
58
|
# @return [String, nil] the model the worker's turns run on, as the
|
|
69
59
|
# last command said
|
|
70
|
-
|
|
60
|
+
# @return [String, nil] the worker's model
|
|
61
|
+
def model_name = @status[:model]
|
|
71
62
|
|
|
72
63
|
# @return [QuestionPrompt, nil] the question waiting for an answer
|
|
73
64
|
attr_reader :question
|
|
@@ -87,8 +78,12 @@ module Samagotchi
|
|
|
87
78
|
# @param delete_session [#call] session id -> deletes it (/exit --delete)
|
|
88
79
|
# @param archive_session [#call] session id -> archives it (/archive);
|
|
89
80
|
# SessionManager.archive_session's result
|
|
81
|
+
# @param wait_at_eof [Boolean] lines come from a pipe or a file (`chi -p
|
|
82
|
+
# X </dev/null`): at their end, detach only once this run's prompts
|
|
83
|
+
# (the -p one too) have had their turns; if one failed, #run says so
|
|
90
84
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
91
|
-
default_input: false,
|
|
85
|
+
default_input: false, wait_at_eof: false,
|
|
86
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
92
87
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
93
88
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
|
|
94
89
|
@client = client
|
|
@@ -116,20 +111,18 @@ module Samagotchi
|
|
|
116
111
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
117
112
|
# the input when their turn fails (a replayed event must not).
|
|
118
113
|
@sent_ids = Set.new
|
|
114
|
+
@wait_at_eof = wait_at_eof
|
|
115
|
+
# Sent and not ended yet (by enqueued_id), the ones merged into the
|
|
116
|
+
# running turn, whether the input has ended, and whether one of this
|
|
117
|
+
# run's prompts failed (wait_at_eof only).
|
|
118
|
+
@open_ids = Set.new
|
|
119
|
+
@merged_ids = Set.new
|
|
120
|
+
@input_ended = false
|
|
121
|
+
@own_failed = false
|
|
119
122
|
# A continue offer is pending: the prompt asks for the answer.
|
|
120
123
|
@continue_offer = nil
|
|
121
|
-
|
|
122
|
-
@
|
|
123
|
-
# The idle status line's data (the REPL's segments).
|
|
124
|
-
@memory_names = []
|
|
125
|
-
# The session's --memory list (shown with the used memories until the
|
|
126
|
-
# first turn records them) and its --mute list, from the join.
|
|
127
|
-
@preloaded_names = []
|
|
128
|
-
@muted_names = []
|
|
129
|
-
# The session that delegated this one, shown as ↳ <short id>.
|
|
130
|
-
@parent_id = nil
|
|
131
|
-
@context_estimate = nil
|
|
132
|
-
@status_rows = nil
|
|
124
|
+
# The status row: the worker's model, ctx, the session's memories.
|
|
125
|
+
@status = StatusRow.new(screen)
|
|
133
126
|
end
|
|
134
127
|
|
|
135
128
|
def running? = @running
|
|
@@ -137,8 +130,10 @@ module Samagotchi
|
|
|
137
130
|
# Follow the session and read input until Ctrl-D, /detach, /exit or
|
|
138
131
|
# the worker goes away. The worker keeps running after a detach; /exit
|
|
139
132
|
# asks it to exit too, and it does unless something still needs it.
|
|
140
|
-
# @return [Symbol] :detached, :closed when the worker went away,
|
|
141
|
-
# :failed when the first command (--model) didn't go through
|
|
133
|
+
# @return [Symbol] :detached, :closed when the worker went away,
|
|
134
|
+
# :failed when the first command (--model) didn't go through, or
|
|
135
|
+
# (wait_at_eof) :turn_failed when one of this run's prompts failed and
|
|
136
|
+
# :unanswered when a question came after the input ended
|
|
142
137
|
# @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
|
|
143
138
|
# Interrupt = Ctrl-C); defaults to Reline
|
|
144
139
|
def run(input: nil)
|
|
@@ -158,8 +153,17 @@ module Samagotchi
|
|
|
158
153
|
case kind
|
|
159
154
|
when :event
|
|
160
155
|
ended = safely_handle(payload)
|
|
161
|
-
return ended if
|
|
162
|
-
|
|
156
|
+
return ended if %i[closed failed unanswered].include?(ended)
|
|
157
|
+
return end_of_input if @input_ended && !own_turns_pending?
|
|
158
|
+
when :line
|
|
159
|
+
if payload.nil? && @wait_at_eof && own_turns_pending?
|
|
160
|
+
# The pipe's end: this run's turns first. An open question
|
|
161
|
+
# has nothing left to answer it.
|
|
162
|
+
return unanswered_question if @question
|
|
163
|
+
|
|
164
|
+
next @input_ended = true
|
|
165
|
+
end
|
|
166
|
+
return end_of_input if submit(payload) == :detach
|
|
163
167
|
when :interrupt then return :detached if interrupt(payload) == :detach
|
|
164
168
|
end
|
|
165
169
|
end
|
|
@@ -186,22 +190,19 @@ module Samagotchi
|
|
|
186
190
|
return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
|
|
187
191
|
when :turn_enqueued then show_enqueued(event)
|
|
188
192
|
when :turn_started then start_turn(event)
|
|
189
|
-
when :turn_completed
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
end_turn("turn failed: #{event[:summary] || "#{event[:message]} (#{event[:error_class]})"}")
|
|
193
|
+
when :turn_completed
|
|
194
|
+
complete_turn(event)
|
|
195
|
+
own_turn_ended(event)
|
|
196
|
+
# The renderer says how it ended (the REPL's words too).
|
|
197
|
+
when :turn_canceled, :turn_failed
|
|
198
|
+
@renderer.call(event)
|
|
199
|
+
end_turn
|
|
200
|
+
own_turn_ended(event)
|
|
198
201
|
when :prompt_restored then restore_prompt(event)
|
|
199
202
|
when :context_status
|
|
200
|
-
@
|
|
201
|
-
refresh_status
|
|
203
|
+
@status.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
202
204
|
when :used_memories_updated
|
|
203
|
-
@
|
|
204
|
-
refresh_status
|
|
205
|
+
@status.update(used_memories: Array(event[:used_memory_names]))
|
|
205
206
|
# Another client's anytime command: its line now, before the cards
|
|
206
207
|
# it shows (its command_ran comes when it's done).
|
|
207
208
|
when :command_queued
|
|
@@ -217,8 +218,12 @@ module Samagotchi
|
|
|
217
218
|
when :continue_resolved then continue_resolved(event)
|
|
218
219
|
# The note comes with the kernel's :pending_input_merged (EventRenderer),
|
|
219
220
|
# after the answer the merge follows.
|
|
220
|
-
when :input_merged then
|
|
221
|
-
when :question_requested
|
|
221
|
+
when :input_merged then merged_own_prompts(event)
|
|
222
|
+
when :question_requested
|
|
223
|
+
# Nothing is left to answer it with.
|
|
224
|
+
return unanswered_question if @input_ended
|
|
225
|
+
|
|
226
|
+
ask(event[:pending_question])
|
|
222
227
|
when :question_answered then question_answered(event)
|
|
223
228
|
when :question_cancelled
|
|
224
229
|
close_question("(question cancelled)") if @question
|
|
@@ -269,23 +274,26 @@ module Samagotchi
|
|
|
269
274
|
|
|
270
275
|
text = line.strip
|
|
271
276
|
return answer_question(text) if @question
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
277
|
+
|
|
278
|
+
# The terminal's own commands (SessionCommands registers them, the
|
|
279
|
+
# REPL reads the same words). Before the continue offer: the
|
|
280
|
+
# leaving ones are no answer to it.
|
|
281
|
+
local = command_registry.lookup_local(text)&.id
|
|
282
|
+
return submit(nil) if local == :detach
|
|
283
|
+
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
284
|
+
return exit_and_archive if local == :archive
|
|
285
|
+
|
|
278
286
|
if @continue_offer
|
|
279
|
-
return answer_continue(text) unless command_registry.command?(text)
|
|
287
|
+
return answer_continue(text) unless local || command_registry.command?(text)
|
|
280
288
|
|
|
281
289
|
# Not an answer: the read left no echo, so show what ran.
|
|
282
290
|
echo_answer(text)
|
|
283
291
|
end
|
|
284
292
|
return if text.empty?
|
|
285
293
|
|
|
286
|
-
if
|
|
294
|
+
if local == :stats
|
|
287
295
|
show_stats
|
|
288
|
-
elsif
|
|
296
|
+
elsif local == :recap
|
|
289
297
|
show_recap
|
|
290
298
|
elsif command_registry.command?(text)
|
|
291
299
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
@@ -314,8 +322,6 @@ module Samagotchi
|
|
|
314
322
|
detach(exit_failed_line(e.message))
|
|
315
323
|
end
|
|
316
324
|
|
|
317
|
-
def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
|
|
318
|
-
|
|
319
325
|
# /exit --delete: the worker exits as for /exit, then the session is
|
|
320
326
|
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
321
327
|
# nothing is deleted.
|
|
@@ -443,9 +449,8 @@ module Samagotchi
|
|
|
443
449
|
return unless event[:model_name]
|
|
444
450
|
|
|
445
451
|
# After the output, so the status row changes with the line saying why.
|
|
446
|
-
|
|
447
|
-
@
|
|
448
|
-
refresh_status
|
|
452
|
+
served = @status[:model] == event[:model_name] ? @status[:served] : nil
|
|
453
|
+
@status.update(model: event[:model_name], default_model: default_model_name, served: served)
|
|
449
454
|
end
|
|
450
455
|
|
|
451
456
|
# Our command the worker couldn't run yet goes back into the prompt, for
|
|
@@ -498,21 +503,22 @@ module Samagotchi
|
|
|
498
503
|
|
|
499
504
|
def send_prompt(text)
|
|
500
505
|
persist_recent_history(text)
|
|
501
|
-
# #memory shorthand becomes words the model reads, as in the REPL.
|
|
502
|
-
prompt = normalize_model_input(text)
|
|
503
506
|
images = attach_images(text)
|
|
504
|
-
return if images.nil?
|
|
507
|
+
return own_prompt_failed if images.nil?
|
|
505
508
|
|
|
506
|
-
options = { prompt:
|
|
509
|
+
options = { prompt: text, client_id: @client_id }
|
|
507
510
|
options[:no_interrupt] = true if @no_interrupt
|
|
508
511
|
options[:images] = images unless images.empty?
|
|
509
512
|
reply = @client.post_turn(**options)
|
|
510
513
|
if reply.status == 202
|
|
511
514
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
512
515
|
@sent_ids << enqueued_id if enqueued_id
|
|
516
|
+
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
513
517
|
return
|
|
514
518
|
end
|
|
515
519
|
|
|
520
|
+
own_prompt_failed
|
|
521
|
+
|
|
516
522
|
detail = reply.json&.fetch("error", nil)
|
|
517
523
|
# Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
|
|
518
524
|
return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
|
|
@@ -520,9 +526,51 @@ module Samagotchi
|
|
|
520
526
|
explained = reply.json&.fetch("detail", nil) if detail == "images_unsupported"
|
|
521
527
|
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
|
|
522
528
|
rescue SystemCallError, IOError => e
|
|
529
|
+
own_prompt_failed
|
|
523
530
|
@screen.commit("could not send the prompt (#{worker_down(e)})")
|
|
524
531
|
end
|
|
525
532
|
|
|
533
|
+
# Whether the input's end must wait: the -p prompt isn't sent yet, or
|
|
534
|
+
# a prompt this run sent hasn't had its turn.
|
|
535
|
+
def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty?
|
|
536
|
+
|
|
537
|
+
# A turn ended: this run's prompt (and ours merged into it) had its turn.
|
|
538
|
+
def own_turn_ended(event)
|
|
539
|
+
return unless @wait_at_eof
|
|
540
|
+
|
|
541
|
+
id = (event[:origin] || {})[:enqueued_id]
|
|
542
|
+
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
543
|
+
@own_failed = true if ours && event[:type] == :turn_failed
|
|
544
|
+
@open_ids.delete(id)
|
|
545
|
+
@open_ids.subtract(@merged_ids)
|
|
546
|
+
@merged_ids.clear
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Ours merged into the running turn: they end with it.
|
|
550
|
+
def merged_own_prompts(event)
|
|
551
|
+
Array(event[:origins]).each do |origin|
|
|
552
|
+
id = origin[:enqueued_id]
|
|
553
|
+
@merged_ids << id if own?(origin[:client_id]) && @open_ids.include?(id)
|
|
554
|
+
end
|
|
555
|
+
nil
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def own_prompt_failed
|
|
559
|
+
@own_failed = true if @wait_at_eof
|
|
560
|
+
nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# The input ended and nothing of ours is left to wait for.
|
|
564
|
+
def end_of_input
|
|
565
|
+
detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
|
|
566
|
+
@own_failed ? :turn_failed : :detached
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def unanswered_question
|
|
570
|
+
detach("A question waits for an answer: chi --attach #{@client.session_id}")
|
|
571
|
+
:unanswered
|
|
572
|
+
end
|
|
573
|
+
|
|
526
574
|
# The prompt's `@path` images, stored in the session's images/ here
|
|
527
575
|
# (this machine, the worker's state dir) and sent as refs: the Bridge
|
|
528
576
|
# never takes a path. nil (after a line saying why) when one can't be.
|
|
@@ -595,6 +643,13 @@ module Samagotchi
|
|
|
595
643
|
# What was typed at the prompt waits for the question to close.
|
|
596
644
|
@set_aside = @reader&.typed_text unless @question
|
|
597
645
|
@question = QuestionPrompt.new(pending)
|
|
646
|
+
# An edit's diff goes above, into the scrollback, once per question
|
|
647
|
+
# (a join gets it from the snapshot and may get the event too).
|
|
648
|
+
unless @previewed_id == @question.id
|
|
649
|
+
@previewed_id = @question.id
|
|
650
|
+
preview = @question.preview_lines(paint: method(:paint))
|
|
651
|
+
@screen.commit(preview.join("\n")) unless preview.empty?
|
|
652
|
+
end
|
|
598
653
|
# The prompt first, so the choices never show under the typed text.
|
|
599
654
|
sync_prompt(keep_text: false)
|
|
600
655
|
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
@@ -661,11 +716,13 @@ module Samagotchi
|
|
|
661
716
|
def restore_prompt(event)
|
|
662
717
|
origin = event[:origin] || {}
|
|
663
718
|
return unless own?(origin[:client_id]) && @sent_ids.include?(origin[:enqueued_id])
|
|
719
|
+
# Lines from a pipe: nobody to retry it (the REPL's rule).
|
|
720
|
+
return if @wait_at_eof
|
|
664
721
|
|
|
665
722
|
if @reader&.prefill(event[:prompt].to_s)
|
|
666
|
-
@screen.commit("
|
|
723
|
+
@screen.commit(turn_end_hint("prompt restored for retry"))
|
|
667
724
|
else
|
|
668
|
-
@screen.commit("
|
|
725
|
+
@screen.commit(turn_end_hint("the failed prompt is in the input history (↑)"))
|
|
669
726
|
end
|
|
670
727
|
end
|
|
671
728
|
|
|
@@ -907,8 +964,9 @@ module Samagotchi
|
|
|
907
964
|
@joined_mid_turn = @running
|
|
908
965
|
return unless turn
|
|
909
966
|
|
|
910
|
-
|
|
911
|
-
|
|
967
|
+
continues = turn[:prompt].nil?
|
|
968
|
+
@renderer.turn_continues = continues
|
|
969
|
+
unless continues && reminder_origin?(turn[:origin] || {})
|
|
912
970
|
@screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
|
|
913
971
|
end
|
|
914
972
|
Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
@@ -945,9 +1003,8 @@ module Samagotchi
|
|
|
945
1003
|
def start_turn(event)
|
|
946
1004
|
@running = true
|
|
947
1005
|
@joined_mid_turn = false
|
|
948
|
-
@turn_continues = event[:prompt].nil?
|
|
949
1006
|
origin = event[:origin] || {}
|
|
950
|
-
if
|
|
1007
|
+
if event[:prompt].nil?
|
|
951
1008
|
# A continue turn (after the offer's yes) has no prompt to show; a
|
|
952
1009
|
# reminder turn shows its reminders (:reminder_injected).
|
|
953
1010
|
@screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin)
|
|
@@ -965,16 +1022,13 @@ module Samagotchi
|
|
|
965
1022
|
end
|
|
966
1023
|
@renderer.call(event)
|
|
967
1024
|
# The turn's summary carries its last context estimate.
|
|
968
|
-
@
|
|
969
|
-
refresh_status
|
|
1025
|
+
@status.update(context: @view.context_status) if @view.context_status
|
|
970
1026
|
@running = false
|
|
971
1027
|
@joined_mid_turn = false
|
|
972
1028
|
close_question("(the turn ended)") if @question
|
|
973
1029
|
end
|
|
974
1030
|
|
|
975
|
-
def end_turn
|
|
976
|
-
@view.finish_thinking_spinner
|
|
977
|
-
@screen.commit(message)
|
|
1031
|
+
def end_turn
|
|
978
1032
|
@running = false
|
|
979
1033
|
@joined_mid_turn = false
|
|
980
1034
|
close_question("(the turn ended)") if @question
|
|
@@ -985,43 +1039,22 @@ module Samagotchi
|
|
|
985
1039
|
# The joining snapshot's session state: the worker's model and the
|
|
986
1040
|
# memories the session used.
|
|
987
1041
|
def take_session_state(state)
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1042
|
+
fields = { served: nil }
|
|
1043
|
+
fields.merge!(model: state[:model_name], default_model: default_model_name) if state[:model_name]
|
|
1044
|
+
fields[:served] = [state[:served_model], state[:served_model_for]] if state[:served_model]
|
|
1045
|
+
fields[:used_memories] = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
|
|
1046
|
+
fields[:preloaded] = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
|
|
1047
|
+
fields[:muted] = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
|
|
1048
|
+
fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
|
|
995
1049
|
# The last turn's ctx, so it shows before this client's first turn.
|
|
996
|
-
|
|
997
|
-
|
|
1050
|
+
fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
|
|
1051
|
+
@status.update(**fields)
|
|
998
1052
|
end
|
|
999
1053
|
|
|
1000
|
-
#
|
|
1001
|
-
#
|
|
1002
|
-
def
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
served, served_for = @served_model
|
|
1006
|
-
segments = [@model_name ? status_model_text(@model_name, default_model_name, served: served, served_for: served_for) : "",
|
|
1007
|
-
@parent_id ? "↳ #{@parent_id.to_s[0, 8]}" : "",
|
|
1008
|
-
status_context_text(estimate: @context_estimate),
|
|
1009
|
-
status_memory_text(@memory_names | @preloaded_names, MEMORY_STICKY_PREVIEW_LIMIT),
|
|
1010
|
-
status_memory_text(@muted_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")].reject(&:empty?)
|
|
1011
|
-
rows = status_rows(segments, @screen.columns - 1)
|
|
1012
|
-
return if rows == @status_rows
|
|
1013
|
-
|
|
1014
|
-
@status_rows = rows
|
|
1015
|
-
rows.empty? ? @screen.clear_slot(:status) : @screen.set_slot(:status, rows)
|
|
1016
|
-
end
|
|
1017
|
-
|
|
1018
|
-
# What the worker's server said it served for a name (a generation, or
|
|
1019
|
-
# the join's session state); dropped when /model switches.
|
|
1020
|
-
def take_served_model(served, served_for, refresh: true)
|
|
1021
|
-
return unless served
|
|
1022
|
-
|
|
1023
|
-
@served_model = [served, served_for]
|
|
1024
|
-
refresh_status if refresh
|
|
1054
|
+
# What the worker's server said it served for a name (a generation);
|
|
1055
|
+
# dropped when /model switches.
|
|
1056
|
+
def take_served_model(served, served_for)
|
|
1057
|
+
@status.update(served: [served, served_for]) if served
|
|
1025
1058
|
end
|
|
1026
1059
|
|
|
1027
1060
|
# The config's default model, as the worker's /model names it.
|
|
@@ -1063,7 +1096,7 @@ module Samagotchi
|
|
|
1063
1096
|
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
1064
1097
|
status = part[:status].to_s
|
|
1065
1098
|
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
|
|
1066
|
-
"#{format_tool_image_suffix(part[:images])}"
|
|
1099
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1067
1100
|
end
|
|
1068
1101
|
end
|
|
1069
1102
|
end
|
|
@@ -8,13 +8,12 @@ require_relative "thinking_line"
|
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
class TerminalUI
|
|
11
|
-
# EventRenderer's view in attached mode
|
|
12
|
-
# thinking feedback is the activity slot, one row right
|
|
13
|
-
# (spinner frame, then the running tool or the newest
|
|
14
|
-
# model's thinking or text, ThinkingLine),
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# with an open prompt.
|
|
11
|
+
# EventRenderer's view in both TUIs (attached mode and the REPL), drawn
|
|
12
|
+
# on a Screen: the thinking feedback is the activity slot, one row right
|
|
13
|
+
# above the prompt (spinner frame, then the running tool or the newest
|
|
14
|
+
# sentence of the model's thinking or text, ThinkingLine), and every
|
|
15
|
+
# finished line is committed output. On a PlainSurface the activity
|
|
16
|
+
# slot is dropped.
|
|
18
17
|
#
|
|
19
18
|
# The spinner turns with time, not per chunk: while the slot is shown a
|
|
20
19
|
# ticker thread redraws it every TICK_INTERVAL, so a turn that gets no
|
|
@@ -59,6 +58,16 @@ module Samagotchi
|
|
|
59
58
|
@screen.commit(text)
|
|
60
59
|
end
|
|
61
60
|
|
|
61
|
+
# Draw on +surface+ from now on (the REPL swaps in its live region
|
|
62
|
+
# after the view is built, and back when it ends): what the slot shows
|
|
63
|
+
# moves there.
|
|
64
|
+
def surface=(surface)
|
|
65
|
+
@lock.synchronize do
|
|
66
|
+
@screen = surface
|
|
67
|
+
redraw_status if status_text
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
62
71
|
# A new turn: its tally starts over too.
|
|
63
72
|
def reset_turn_feedback
|
|
64
73
|
@lock.synchronize do
|
|
@@ -81,7 +90,7 @@ module Samagotchi
|
|
|
81
90
|
|
|
82
91
|
def generation_feedback_retrying(event)
|
|
83
92
|
@lock.synchronize do
|
|
84
|
-
@retry =
|
|
93
|
+
@retry = retry_text(event)
|
|
85
94
|
# The retry streams from the start: wait for its first token again.
|
|
86
95
|
@waiting_since = @clock.call if @thinking
|
|
87
96
|
redraw_status
|
|
@@ -194,9 +203,6 @@ module Samagotchi
|
|
|
194
203
|
@context_status = status if status
|
|
195
204
|
end
|
|
196
205
|
|
|
197
|
-
# The REPL's sticky memories line has no source in attached mode.
|
|
198
|
-
def emit_active_memories_line; end
|
|
199
|
-
|
|
200
206
|
private
|
|
201
207
|
|
|
202
208
|
def reset_feedback_state
|
|
@@ -207,6 +213,15 @@ module Samagotchi
|
|
|
207
213
|
@waiting_since = nil
|
|
208
214
|
end
|
|
209
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
|
+
|
|
210
225
|
def tally_key(event)
|
|
211
226
|
[event[:iteration].to_i, event[:call_index].to_i]
|
|
212
227
|
end
|
|
@@ -246,7 +261,7 @@ module Samagotchi
|
|
|
246
261
|
def status_text
|
|
247
262
|
now = @clock.call
|
|
248
263
|
frame = FRAMES[((now - @origin) / FRAME_INTERVAL).floor % FRAMES.length]
|
|
249
|
-
return "#{frame} #{@retry}" if @retry
|
|
264
|
+
return "#{frame} #{paint(@retry, 90)}" if @retry
|
|
250
265
|
return "#{frame} #{@tool}…" if @tool
|
|
251
266
|
return "#{frame} #{@init_tasks.values.join(" · ")}…" if !@thinking && @init_tasks.any?
|
|
252
267
|
return nil unless @thinking
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "set"
|
|
4
|
+
require_relative "formatting"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class TerminalUI
|
|
7
8
|
# Renders one UI's view of Engine turn events: the thinking spinner while
|
|
8
9
|
# the model generates, a `tool>` line per completed tool call, and the
|
|
9
10
|
# answer (with any tool activity not already shown) from :turn_completed's
|
|
10
|
-
# turn_summary. It is the `on_event:` sink the REPL
|
|
11
|
+
# turn_summary, and how a turn ended. It is the `on_event:` sink the REPL
|
|
12
|
+
# hands Engine#run_turn, and renders what attached mode gets over the
|
|
13
|
+
# Bridge.
|
|
11
14
|
#
|
|
12
|
-
# Drawing is delegated to a +view+ (
|
|
13
|
-
#
|
|
15
|
+
# Drawing is delegated to a +view+ (an AttachedView in both TUIs). The
|
|
16
|
+
# renderer keeps
|
|
14
17
|
# only per-turn bookkeeping that must come from the events themselves:
|
|
15
18
|
# which tool lines were already streamed, and when each tool call started.
|
|
16
19
|
# Events that came over the Bridge as JSON (string keys) are symbolized first.
|
|
@@ -22,8 +25,14 @@ module Samagotchi
|
|
|
22
25
|
@tool_started_at = {}
|
|
23
26
|
# Card ids shown so far (across turns): one shown again is updated.
|
|
24
27
|
@card_ids = Set.new
|
|
28
|
+
@turn_continues = false
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
# Whether the running turn continues an earlier one (a continue or a
|
|
32
|
+
# reminder turn): set from :turn_started, or by a UI that joined the
|
|
33
|
+
# turn mid-way. A canceled one gets no rollback hint.
|
|
34
|
+
attr_writer :turn_continues
|
|
35
|
+
|
|
27
36
|
# Deep-convert a string-keyed event (JSON from the Bridge) to the local
|
|
28
37
|
# shape: symbol keys and a symbol :type. Other values stay as they are.
|
|
29
38
|
def self.symbolize(event)
|
|
@@ -45,6 +54,7 @@ module Samagotchi
|
|
|
45
54
|
case event[:type]
|
|
46
55
|
when :turn_started
|
|
47
56
|
begin_turn
|
|
57
|
+
@turn_continues = event[:continue] ? true : event[:prompt].nil?
|
|
48
58
|
Array(event[:images]).each { |ref| @view.print_line(@view.format_image_line(ref)) }
|
|
49
59
|
when :generation_started
|
|
50
60
|
@view.generation_feedback_started(event)
|
|
@@ -58,7 +68,8 @@ module Samagotchi
|
|
|
58
68
|
when :tool_call_completed
|
|
59
69
|
@view.clear_generation_retry
|
|
60
70
|
@view.tool_call_feedback_completed(event)
|
|
61
|
-
render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images]
|
|
71
|
+
render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images],
|
|
72
|
+
diff: event[:diff])
|
|
62
73
|
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
63
74
|
@view.generation_feedback_finished
|
|
64
75
|
when :pending_input_merged
|
|
@@ -68,6 +79,15 @@ module Samagotchi
|
|
|
68
79
|
@view.print_line(@view.format_empty_retry_line(event))
|
|
69
80
|
when :turn_completed
|
|
70
81
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
82
|
+
when :turn_canceled
|
|
83
|
+
@view.finish_thinking_spinner
|
|
84
|
+
@view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms]))
|
|
85
|
+
@view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) unless @turn_continues
|
|
86
|
+
when :turn_failed
|
|
87
|
+
# A provider error's one-line summary, else the message (an image
|
|
88
|
+
# that couldn't be used).
|
|
89
|
+
@view.finish_thinking_spinner
|
|
90
|
+
@view.print_line(@view.turn_failed_line(event[:summary] || event[:message], event[:duration_ms]))
|
|
71
91
|
when :guardrail_warning
|
|
72
92
|
@view.print_line(self.class.load_warning_line(event))
|
|
73
93
|
when :hook_notice
|
|
@@ -104,7 +124,8 @@ module Samagotchi
|
|
|
104
124
|
# @param event [Hash] :hook_notice event or a snapshot part with
|
|
105
125
|
# hook:, text:, level:
|
|
106
126
|
def self.hook_notice_line(event)
|
|
107
|
-
|
|
127
|
+
# chi's own notices name themselves with a bare word (thinking).
|
|
128
|
+
label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || event[:hook].to_s[/\A[a-z][a-z0-9-]*\z/] || "hook"
|
|
108
129
|
text = event[:text].to_s
|
|
109
130
|
text = "warning: #{text}" if event[:level].to_s == "warn"
|
|
110
131
|
"#{label}> #{text}"
|
|
@@ -134,7 +155,7 @@ module Samagotchi
|
|
|
134
155
|
end
|
|
135
156
|
|
|
136
157
|
# Render a finished turn: tool activity the stream did not already show,
|
|
137
|
-
# the
|
|
158
|
+
# the answer, and the iteration-limit notice.
|
|
138
159
|
# @param summary [Hash] Engine#turn_summary
|
|
139
160
|
def render_turn_summary(summary)
|
|
140
161
|
@view.finish_thinking_spinner
|
|
@@ -144,7 +165,6 @@ module Samagotchi
|
|
|
144
165
|
|
|
145
166
|
@view.print_line(@view.format_tool_activity_line(activity))
|
|
146
167
|
end
|
|
147
|
-
@view.emit_active_memories_line
|
|
148
168
|
@view.print_line(summary[:output])
|
|
149
169
|
@view.print_line("iteration limit reached") if summary[:resumable]
|
|
150
170
|
end
|
|
@@ -162,12 +182,13 @@ module Samagotchi
|
|
|
162
182
|
|
|
163
183
|
private
|
|
164
184
|
|
|
165
|
-
def render_streamed_tool_activity(activity, duration_ms:, images: nil)
|
|
185
|
+
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
|
|
166
186
|
return if activity.nil?
|
|
167
187
|
|
|
168
188
|
@streamed_tool_activity[tool_activity_key(activity)] += 1
|
|
169
189
|
line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
|
|
170
190
|
line += @view.format_tool_image_suffix(images) if images&.any?
|
|
191
|
+
line += @view.format_tool_diff_suffix(diff) if diff
|
|
171
192
|
@view.print_line(line)
|
|
172
193
|
end
|
|
173
194
|
|