samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- 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 +180 -25
- 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/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- 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 +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -12,6 +12,7 @@ require_relative "reline_seam"
|
|
|
12
12
|
require_relative "../bridge_client"
|
|
13
13
|
require_relative "../log"
|
|
14
14
|
require_relative "../context_note"
|
|
15
|
+
require_relative "../steer"
|
|
15
16
|
require_relative "../model_profile"
|
|
16
17
|
require_relative "../output_formatter"
|
|
17
18
|
require_relative "../session_commands"
|
|
@@ -30,15 +31,7 @@ module Samagotchi
|
|
|
30
31
|
include Formatting
|
|
31
32
|
include InputSupport
|
|
32
33
|
|
|
33
|
-
STATS_COMMAND = "/stats"
|
|
34
|
-
RECAP_COMMAND = "/recap"
|
|
35
34
|
PROMPT = "> "
|
|
36
|
-
# Detach and leave the worker up.
|
|
37
|
-
DETACH_COMMANDS = %w[/detach].freeze
|
|
38
|
-
# Detach and ask the worker to exit (bare `exit` too, as in the REPL).
|
|
39
|
-
EXIT_COMMANDS = %w[/exit /quit].freeze
|
|
40
|
-
# After an exit command: delete the session once the worker has gone.
|
|
41
|
-
DELETE_FLAG = "--delete"
|
|
42
35
|
# How long /exit --delete waits for the worker to let go.
|
|
43
36
|
DELETE_WAIT = 10
|
|
44
37
|
# A second Ctrl-C at an empty idle prompt within this many seconds detaches.
|
|
@@ -82,11 +75,15 @@ module Samagotchi
|
|
|
82
75
|
# the first read (a new session with no -p, as the REPL)
|
|
83
76
|
# @param clock [#call] monotonic seconds (the Ctrl-C detach window)
|
|
84
77
|
# @param delete_session [#call] session id -> deletes it (/exit --delete)
|
|
78
|
+
# @param archive_session [#call] session id -> archives it (/archive);
|
|
79
|
+
# SessionManager.archive_session's result
|
|
85
80
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
86
81
|
default_input: false, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
87
|
-
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) }
|
|
82
|
+
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
83
|
+
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
|
|
88
84
|
@client = client
|
|
89
85
|
@delete_session = delete_session
|
|
86
|
+
@archive_session = archive_session
|
|
90
87
|
@screen = screen
|
|
91
88
|
@client_id = client_id
|
|
92
89
|
@view = AttachedView.new(screen)
|
|
@@ -198,7 +195,7 @@ module Samagotchi
|
|
|
198
195
|
# Another client's anytime command: its line now, before the cards
|
|
199
196
|
# it shows (its command_ran comes when it's done).
|
|
200
197
|
when :command_queued
|
|
201
|
-
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id])
|
|
198
|
+
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
|
|
202
199
|
when :command_ran
|
|
203
200
|
command_ran(event)
|
|
204
201
|
return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
|
|
@@ -262,22 +259,26 @@ module Samagotchi
|
|
|
262
259
|
|
|
263
260
|
text = line.strip
|
|
264
261
|
return answer_question(text) if @question
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
262
|
+
|
|
263
|
+
# The terminal's own commands (SessionCommands registers them, the
|
|
264
|
+
# REPL reads the same words). Before the continue offer: the
|
|
265
|
+
# leaving ones are no answer to it.
|
|
266
|
+
local = command_registry.lookup_local(text)&.id
|
|
267
|
+
return submit(nil) if local == :detach
|
|
268
|
+
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
269
|
+
return exit_and_archive if local == :archive
|
|
270
|
+
|
|
270
271
|
if @continue_offer
|
|
271
|
-
return answer_continue(text) unless command_registry.command?(text)
|
|
272
|
+
return answer_continue(text) unless local || command_registry.command?(text)
|
|
272
273
|
|
|
273
274
|
# Not an answer: the read left no echo, so show what ran.
|
|
274
275
|
echo_answer(text)
|
|
275
276
|
end
|
|
276
277
|
return if text.empty?
|
|
277
278
|
|
|
278
|
-
if
|
|
279
|
+
if local == :stats
|
|
279
280
|
show_stats
|
|
280
|
-
elsif
|
|
281
|
+
elsif local == :recap
|
|
281
282
|
show_recap
|
|
282
283
|
elsif command_registry.command?(text)
|
|
283
284
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
@@ -306,8 +307,6 @@ module Samagotchi
|
|
|
306
307
|
detach(exit_failed_line(e.message))
|
|
307
308
|
end
|
|
308
309
|
|
|
309
|
-
def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
|
|
310
|
-
|
|
311
310
|
# /exit --delete: the worker exits as for /exit, then the session is
|
|
312
311
|
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
313
312
|
# nothing is deleted.
|
|
@@ -329,6 +328,27 @@ module Samagotchi
|
|
|
329
328
|
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
|
|
330
329
|
end
|
|
331
330
|
|
|
331
|
+
# /archive: the worker is asked to exit as for /exit, then the session
|
|
332
|
+
# is archived. A worker staying up for another UI (or reminders, a
|
|
333
|
+
# queued prompt) is stopped by the archive, as the web's archive
|
|
334
|
+
# stops it; a turn running refuses it. An empty session: the worker
|
|
335
|
+
# deletes it as it leaves.
|
|
336
|
+
def exit_and_archive
|
|
337
|
+
reply = @client.request_exit(client_id: @client_id)
|
|
338
|
+
id = @client.session_id
|
|
339
|
+
return detach("Detached; the session was empty, so it is discarded.") if reply.status == 200 && discards?(reply)
|
|
340
|
+
return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
|
|
341
|
+
|
|
342
|
+
result = @archive_session.call(id)
|
|
343
|
+
return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
|
|
344
|
+
|
|
345
|
+
detach("Detached; archived session #{id}. chi sessions list --archived finds it.")
|
|
346
|
+
rescue SystemCallError, IOError => e
|
|
347
|
+
detach("#{exit_failed_line(e.message)} Not archived.")
|
|
348
|
+
rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
|
|
349
|
+
detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: chi --attach #{id}")
|
|
350
|
+
end
|
|
351
|
+
|
|
332
352
|
def exit_line(reply)
|
|
333
353
|
id = @client.session_id
|
|
334
354
|
error = reply.json&.fetch("error", nil)
|
|
@@ -401,7 +421,7 @@ module Samagotchi
|
|
|
401
421
|
end
|
|
402
422
|
|
|
403
423
|
def command_ran(event)
|
|
404
|
-
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime]
|
|
424
|
+
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
|
|
405
425
|
output = event[:output].to_s
|
|
406
426
|
if output.empty?
|
|
407
427
|
nil
|
|
@@ -469,12 +489,10 @@ module Samagotchi
|
|
|
469
489
|
|
|
470
490
|
def send_prompt(text)
|
|
471
491
|
persist_recent_history(text)
|
|
472
|
-
# #memory shorthand becomes words the model reads, as in the REPL.
|
|
473
|
-
prompt = normalize_model_input(text)
|
|
474
492
|
images = attach_images(text)
|
|
475
493
|
return if images.nil?
|
|
476
494
|
|
|
477
|
-
options = { prompt:
|
|
495
|
+
options = { prompt: text, client_id: @client_id }
|
|
478
496
|
options[:no_interrupt] = true if @no_interrupt
|
|
479
497
|
options[:images] = images unless images.empty?
|
|
480
498
|
reply = @client.post_turn(**options)
|
|
@@ -566,6 +584,13 @@ module Samagotchi
|
|
|
566
584
|
# What was typed at the prompt waits for the question to close.
|
|
567
585
|
@set_aside = @reader&.typed_text unless @question
|
|
568
586
|
@question = QuestionPrompt.new(pending)
|
|
587
|
+
# An edit's diff goes above, into the scrollback, once per question
|
|
588
|
+
# (a join gets it from the snapshot and may get the event too).
|
|
589
|
+
unless @previewed_id == @question.id
|
|
590
|
+
@previewed_id = @question.id
|
|
591
|
+
preview = @question.preview_lines(paint: method(:paint))
|
|
592
|
+
@screen.commit(preview.join("\n")) unless preview.empty?
|
|
593
|
+
end
|
|
569
594
|
# The prompt first, so the choices never show under the typed text.
|
|
570
595
|
sync_prompt(keep_text: false)
|
|
571
596
|
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
@@ -816,12 +841,15 @@ module Samagotchi
|
|
|
816
841
|
# The id stays findable: the detach line and chi sessions list show it.
|
|
817
842
|
Log.session_id = @client.session_id
|
|
818
843
|
Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
|
|
819
|
-
last_user = exchange.rindex { |m| m
|
|
844
|
+
last_user = exchange.rindex { |m| Steer.prompt?(m) }
|
|
820
845
|
render_join_notes(messages)
|
|
821
846
|
return unless last_user
|
|
822
847
|
|
|
823
848
|
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
824
849
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
850
|
+
exchange[(last_user + 1)..].select { |m| Steer.steer?(m) }.each do |m|
|
|
851
|
+
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
852
|
+
end
|
|
825
853
|
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
826
854
|
# be only a tool call.
|
|
827
855
|
answer = exchange[(last_user + 1)..].reverse_each
|
|
@@ -834,7 +862,7 @@ module Samagotchi
|
|
|
834
862
|
# The context notes that came since the last prompt (all of them in a
|
|
835
863
|
# session with none), after the exchange the header shows.
|
|
836
864
|
def render_join_notes(messages, after_exchange: false)
|
|
837
|
-
last_user = messages.rindex { |m| m
|
|
865
|
+
last_user = messages.rindex { |m| Steer.prompt?(m) }
|
|
838
866
|
return if after_exchange != !last_user.nil?
|
|
839
867
|
|
|
840
868
|
messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
|
|
@@ -894,6 +922,7 @@ module Samagotchi
|
|
|
894
922
|
@screen.commit(snapshot_tool_line(part))
|
|
895
923
|
end
|
|
896
924
|
when "input" then @screen.commit("input> #{part[:text]}")
|
|
925
|
+
when "steer" then @screen.commit(format_steer_line(source: part[:source], text: part[:text]))
|
|
897
926
|
when "reminder" then @screen.commit(reminder_line(part[:reminders]))
|
|
898
927
|
when "hook_notice" then @screen.commit(EventRenderer.hook_notice_line(part))
|
|
899
928
|
end
|
|
@@ -1029,8 +1058,8 @@ module Samagotchi
|
|
|
1029
1058
|
params = part[:params].to_s.strip
|
|
1030
1059
|
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
1031
1060
|
status = part[:status].to_s
|
|
1032
|
-
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status
|
|
1033
|
-
"#{format_tool_image_suffix(part[:images])}"
|
|
1061
|
+
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
|
|
1062
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1034
1063
|
end
|
|
1035
1064
|
end
|
|
1036
1065
|
end
|
|
@@ -55,14 +55,20 @@ module Samagotchi
|
|
|
55
55
|
when :tool_call_started
|
|
56
56
|
@tool_started_at[tool_call_key(event)] = @clock.call
|
|
57
57
|
@view.tool_call_feedback_started(event)
|
|
58
|
+
when :used_memories_updated
|
|
59
|
+
@view.used_memories_updated(event) if @view.respond_to?(:used_memories_updated)
|
|
58
60
|
when :tool_call_completed
|
|
59
61
|
@view.clear_generation_retry
|
|
60
62
|
@view.tool_call_feedback_completed(event)
|
|
61
|
-
render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images]
|
|
63
|
+
render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images],
|
|
64
|
+
diff: event[:diff])
|
|
62
65
|
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
63
66
|
@view.generation_feedback_finished
|
|
64
67
|
when :pending_input_merged
|
|
65
68
|
render_merge(event)
|
|
69
|
+
when :empty_answer_retry
|
|
70
|
+
@view.finish_thinking_spinner
|
|
71
|
+
@view.print_line(@view.format_empty_retry_line(event))
|
|
66
72
|
when :turn_completed
|
|
67
73
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
68
74
|
when :guardrail_warning
|
|
@@ -101,7 +107,8 @@ module Samagotchi
|
|
|
101
107
|
# @param event [Hash] :hook_notice event or a snapshot part with
|
|
102
108
|
# hook:, text:, level:
|
|
103
109
|
def self.hook_notice_line(event)
|
|
104
|
-
|
|
110
|
+
# chi's own notices name themselves with a bare word (thinking).
|
|
111
|
+
label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || event[:hook].to_s[/\A[a-z][a-z0-9-]*\z/] || "hook"
|
|
105
112
|
text = event[:text].to_s
|
|
106
113
|
text = "warning: #{text}" if event[:level].to_s == "warn"
|
|
107
114
|
"#{label}> #{text}"
|
|
@@ -146,23 +153,26 @@ module Samagotchi
|
|
|
146
153
|
@view.print_line("iteration limit reached") if summary[:resumable]
|
|
147
154
|
end
|
|
148
155
|
|
|
149
|
-
# Steering merged into the running turn: the answer it follows first
|
|
150
|
-
# (the turn summary shows only the last one), then the note
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
# Steering merged into the running turn: the answer it follows first
|
|
157
|
+
# (the turn summary shows only the last one), then the note, then a
|
|
158
|
+
# line per plugin steer (none for a steer-only merge).
|
|
159
|
+
def render_merge(event)
|
|
160
|
+
@view.finish_thinking_spinner
|
|
161
|
+
@view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
|
|
162
|
+
count = event[:count].to_i
|
|
163
|
+
@view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)") if count.positive?
|
|
164
|
+
Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
|
|
165
|
+
end
|
|
157
166
|
|
|
158
167
|
private
|
|
159
168
|
|
|
160
|
-
def render_streamed_tool_activity(activity, duration_ms:, images: nil)
|
|
169
|
+
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
|
|
161
170
|
return if activity.nil?
|
|
162
171
|
|
|
163
172
|
@streamed_tool_activity[tool_activity_key(activity)] += 1
|
|
164
173
|
line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
|
|
165
174
|
line += @view.format_tool_image_suffix(images) if images&.any?
|
|
175
|
+
line += @view.format_tool_diff_suffix(diff) if diff
|
|
166
176
|
@view.print_line(line)
|
|
167
177
|
end
|
|
168
178
|
|
|
@@ -13,9 +13,13 @@ module Samagotchi
|
|
|
13
13
|
params = activity[:params].to_s.strip
|
|
14
14
|
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
15
15
|
status = activity[:status].to_s
|
|
16
|
-
status_color = status == "ok" ? 32 : 31
|
|
17
16
|
elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
|
|
18
|
-
"#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color)}#{elapsed_suffix}"
|
|
17
|
+
"#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
21
|
+
def status_color(status)
|
|
22
|
+
{ "ok" => 32, "stopped" => 33 }.fetch(status, 31)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
# "[image shot.png 1280×800 · ~1.3k tokens]", dim: a turn's image.
|
|
@@ -23,6 +27,21 @@ module Samagotchi
|
|
|
23
27
|
paint("[image #{ImageRef.label(ImageStore.symbolize(ref))}]", 90)
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
STEER_PREVIEW = 80
|
|
31
|
+
|
|
32
|
+
# "check-in> nudged: <text>", dim, one line cut: a plugin's steer
|
|
33
|
+
# (Steer) in the running turn or the join's last exchange.
|
|
34
|
+
def format_steer_line(source:, text:)
|
|
35
|
+
first = text.to_s.strip.split("\n").first.to_s
|
|
36
|
+
first = "#{first[0, STEER_PREVIEW - 1]}…" if first.length > STEER_PREVIEW || text.to_s.strip.include?("\n")
|
|
37
|
+
paint("#{source.to_s.empty? ? "plugin" : source}> nudged: #{first}", 90)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# "↻ empty answer, asking again (1/1)": the loop retries an empty answer.
|
|
41
|
+
def format_empty_retry_line(event)
|
|
42
|
+
paint("↻ empty answer, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
43
|
+
end
|
|
44
|
+
|
|
26
45
|
# " → image 1280×800" after a tool line whose tool read an image.
|
|
27
46
|
def format_tool_image_suffix(images)
|
|
28
47
|
refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
|
|
@@ -31,6 +50,15 @@ module Samagotchi
|
|
|
31
50
|
" #{paint(refs.map { |ref| "→ image #{ref[:width]}×#{ref[:height]}" }.join(", "), 90)}"
|
|
32
51
|
end
|
|
33
52
|
|
|
53
|
+
# An edit/write row's change, after the row: " +3 −1" (green, red).
|
|
54
|
+
# +diff+ is EditPreview's hash, symbol or string keys (SSE, snapshot).
|
|
55
|
+
def format_tool_diff_suffix(diff)
|
|
56
|
+
return "" unless diff.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
get = ->(key) { diff[key] || diff[key.to_s] }
|
|
59
|
+
" #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
34
62
|
# A card (Engine#show_card) as a framed block: the title and its
|
|
35
63
|
# source, the body as wrapped plain text (no terminal markdown), and
|
|
36
64
|
# one `→ <command>` line per action, the label after it when it says
|
|
@@ -229,11 +257,11 @@ module Samagotchi
|
|
|
229
257
|
def format_session_metrics(snapshot)
|
|
230
258
|
return "(no metrics yet)" unless snapshot.is_a?(Hash)
|
|
231
259
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
token_src_label = case token_src
|
|
260
|
+
tokens = snapshot[:tokens] || {}
|
|
261
|
+
token_src_label = case tokens[:source]
|
|
235
262
|
when "server" then "server-reported"
|
|
236
263
|
when "estimate" then "estimated (chars/4)"
|
|
264
|
+
when "mixed" then "server-reported, some estimated"
|
|
237
265
|
else "n/a"
|
|
238
266
|
end
|
|
239
267
|
|
|
@@ -245,13 +273,17 @@ module Samagotchi
|
|
|
245
273
|
lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
246
274
|
end
|
|
247
275
|
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
248
|
-
|
|
276
|
+
# Summed over every request: each prompt is sent in full again.
|
|
277
|
+
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})"
|
|
249
278
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
250
279
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
251
280
|
lines << "retries: #{snapshot[:retries]}"
|
|
252
|
-
|
|
253
|
-
|
|
281
|
+
context = snapshot[:context] || {}
|
|
282
|
+
if context[:used_tokens]
|
|
283
|
+
pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
|
|
284
|
+
lines << "context used: #{context[:used_tokens]} tokens#{pct}"
|
|
254
285
|
end
|
|
286
|
+
lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
|
|
255
287
|
lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
|
|
256
288
|
if snapshot[:served_model]
|
|
257
289
|
asked = snapshot[:served_model_for]
|
|
@@ -11,10 +11,10 @@ require_relative "../session_commands"
|
|
|
11
11
|
module Samagotchi
|
|
12
12
|
class TerminalUI
|
|
13
13
|
# Input the REPL and the attached TUI share: the persistent prompt
|
|
14
|
-
# history, Tab completion (/commands, @path in the cwd, #memory
|
|
15
|
-
# #
|
|
16
|
-
# one-shot prefill of the next read (the default
|
|
17
|
-
# back after a failed turn).
|
|
14
|
+
# history, Tab completion (/commands, @path in the cwd, #memory, which
|
|
15
|
+
# inserts #name and the model reads it as typed), multiline reads at the
|
|
16
|
+
# main prompt, and a one-shot prefill of the next read (the default
|
|
17
|
+
# input, a prompt given back after a failed turn).
|
|
18
18
|
#
|
|
19
19
|
# Included for private use; it keeps state in @next_input_prefill and
|
|
20
20
|
# reads @no_default_input.
|
|
@@ -204,21 +204,6 @@ module Samagotchi
|
|
|
204
204
|
}
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
-
def normalize_model_input(input)
|
|
208
|
-
input.to_s.gsub(/(^|[^\w\/])#((?:project|system)\/)?([a-zA-Z0-9][a-zA-Z0-9_-]*)/) do
|
|
209
|
-
prefix = Regexp.last_match(1)
|
|
210
|
-
scoped = Regexp.last_match(2).to_s
|
|
211
|
-
name = Regexp.last_match(3)
|
|
212
|
-
scope = scoped.delete_suffix("/")
|
|
213
|
-
normalized = if scope.empty?
|
|
214
|
-
"memory \"#{name}\""
|
|
215
|
-
else
|
|
216
|
-
"memory \"#{name}\" in #{scope} scope"
|
|
217
|
-
end
|
|
218
|
-
"#{prefix}#{normalized}"
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
|
|
222
207
|
def history_file_path
|
|
223
208
|
explicit = ENV[PROMPT_HISTORY_ENV].to_s.strip
|
|
224
209
|
return explicit unless explicit.empty?
|
|
@@ -240,7 +225,10 @@ module Samagotchi
|
|
|
240
225
|
nil
|
|
241
226
|
end
|
|
242
227
|
|
|
228
|
+
# A scratch session keeps nothing, its typed lines neither.
|
|
243
229
|
def persist_recent_history(input)
|
|
230
|
+
return if @scratch
|
|
231
|
+
|
|
244
232
|
entries = normalize_history_entries(load_history_entries_from_disk)
|
|
245
233
|
entries << input
|
|
246
234
|
trimmed_entries = entries.last(PROMPT_HISTORY_LIMIT)
|
|
@@ -14,6 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# an optional "; reason"; a substring would pick the wrong one ("y" is
|
|
15
15
|
# in "Deny").
|
|
16
16
|
class QuestionPrompt
|
|
17
|
+
DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
|
|
18
|
+
|
|
17
19
|
# A parsed answer. +error+ set: re-ask. +note+: print, then accept.
|
|
18
20
|
Answer = Struct.new(:selected, :freeform, :error, :note, keyword_init: true) do
|
|
19
21
|
def ok? = error.nil?
|
|
@@ -24,6 +26,8 @@ module Samagotchi
|
|
|
24
26
|
# @param pending [Hash] Engine#pending_question (symbol or string keys)
|
|
25
27
|
def initialize(pending)
|
|
26
28
|
field = ->(key) { pending[key] || pending[key.to_s] }
|
|
29
|
+
approval = field.(:approval)
|
|
30
|
+
@preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
|
|
27
31
|
@id = field.(:id).to_s
|
|
28
32
|
@question = field.(:question).to_s
|
|
29
33
|
@options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
@@ -49,6 +53,34 @@ module Samagotchi
|
|
|
49
53
|
hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
# The most diff lines #preview_lines prints; the web shows them all.
|
|
57
|
+
PREVIEW_LINES = 40
|
|
58
|
+
|
|
59
|
+
# An edit/write approval's dry-run diff (approval.preview, symbol or
|
|
60
|
+
# string keys: nested keys are strings after a reload or SSE), as
|
|
61
|
+
# lines to print above the slot: coloured +/-, dim @@, cut at
|
|
62
|
+
# PREVIEW_LINES. [] for none.
|
|
63
|
+
def preview_lines(paint: ->(text, _code) { text })
|
|
64
|
+
preview = @preview
|
|
65
|
+
return [] unless preview.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
get = ->(key) { preview[key] || preview[key.to_s] }
|
|
68
|
+
return [paint.("this edit would fail: #{get.(:error)}", 33)] if get.(:error)
|
|
69
|
+
return [paint.("diff not shown: #{get.(:skipped)}", 2)] if get.(:skipped)
|
|
70
|
+
|
|
71
|
+
lines = get.(:text).to_s.split("\n")
|
|
72
|
+
return [] if lines.empty?
|
|
73
|
+
|
|
74
|
+
# TextDiff's own cut note ("… N more lines") counts toward the rest.
|
|
75
|
+
cut = lines.last.to_s[/\A… (\d+) more lines\z/, 1]
|
|
76
|
+
lines.pop if cut
|
|
77
|
+
hidden = [lines.size - PREVIEW_LINES, 0].max + cut.to_i
|
|
78
|
+
shown = lines.first(PREVIEW_LINES).map { |line| paint.(line, diff_code(line)) }
|
|
79
|
+
shown.unshift(paint.("new file", 2)) if get.(:new_file)
|
|
80
|
+
shown << paint.("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
|
|
81
|
+
shown
|
|
82
|
+
end
|
|
83
|
+
|
|
52
84
|
# The one line that stays in the scrollback once the question closes:
|
|
53
85
|
# the question and what became of it.
|
|
54
86
|
# @param outcome [String] the answer (#answer_text) or what closed it
|
|
@@ -148,6 +180,9 @@ module Samagotchi
|
|
|
148
180
|
found = options.find { |o| o.downcase == tok.downcase || o.downcase.include?(tok.downcase) }
|
|
149
181
|
found ? [found, nil] : [nil, "Unknown option '#{tok}'. Use numbers 1-#{options.size} or exact labels."]
|
|
150
182
|
end
|
|
183
|
+
|
|
184
|
+
# "+" green, "-" red, "@@" and "\ No newline" dim, context plain.
|
|
185
|
+
def diff_code(line) = DIFF_CODES.fetch(line[0], 0)
|
|
151
186
|
end
|
|
152
187
|
end
|
|
153
188
|
end
|