samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "event_renderer"
|
|
5
|
+
require_relative "formatting"
|
|
6
|
+
require_relative "attached_view"
|
|
7
|
+
require_relative "input_support"
|
|
8
|
+
require_relative "image_input"
|
|
9
|
+
require_relative "line_reader"
|
|
10
|
+
require_relative "question_prompt"
|
|
11
|
+
require_relative "reline_seam"
|
|
12
|
+
require_relative "../bridge_client"
|
|
13
|
+
require_relative "../log"
|
|
14
|
+
require_relative "../context_note"
|
|
15
|
+
require_relative "../model_profile"
|
|
16
|
+
require_relative "../output_formatter"
|
|
17
|
+
require_relative "../session_commands"
|
|
18
|
+
require_relative "../session_manager"
|
|
19
|
+
|
|
20
|
+
module Samagotchi
|
|
21
|
+
class TerminalUI
|
|
22
|
+
# The TUI attached to a session that a worker owns (`chi --attach`):
|
|
23
|
+
# a thin Bridge client, with no Engine and no OwnerLock of its own.
|
|
24
|
+
#
|
|
25
|
+
# #handle_event turns the worker's event stream into terminal output:
|
|
26
|
+
# the snapshot a join starts with (the last exchange, queued prompts,
|
|
27
|
+
# the turn so far), prompts other clients sent, and each turn through the
|
|
28
|
+
# same EventRenderer the local REPL uses, drawn by an AttachedView.
|
|
29
|
+
class AttachedLoop
|
|
30
|
+
include Formatting
|
|
31
|
+
include InputSupport
|
|
32
|
+
|
|
33
|
+
STATS_COMMAND = "/stats"
|
|
34
|
+
RECAP_COMMAND = "/recap"
|
|
35
|
+
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
|
+
# How long /exit --delete waits for the worker to let go.
|
|
43
|
+
DELETE_WAIT = 10
|
|
44
|
+
# A second Ctrl-C at an empty idle prompt within this many seconds detaches.
|
|
45
|
+
DETACH_WINDOW = 2.0
|
|
46
|
+
DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
|
|
47
|
+
# Why a request the Bridge read after its deadline didn't go through.
|
|
48
|
+
LATE = "worker not answering in time"
|
|
49
|
+
# Why the worker stayed up after /exit, by the reason it gave.
|
|
50
|
+
HELD_REASONS = {
|
|
51
|
+
"turn_running" => "a turn is running", "input_queued" => "prompts are queued",
|
|
52
|
+
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
53
|
+
"reminders" => "reminders are set", "starting" => "the worker is still starting"
|
|
54
|
+
}.freeze
|
|
55
|
+
ROLLBACK_HINT = "partial progress kept in context; !rollback restores the pre-turn state"
|
|
56
|
+
# How much of the last answer a join shows.
|
|
57
|
+
JOIN_ANSWER_LINES = 12
|
|
58
|
+
JOIN_ANSWER_CHARS = 1200
|
|
59
|
+
|
|
60
|
+
# Prompt labels by the sender's client_id prefix.
|
|
61
|
+
CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate" }.freeze
|
|
62
|
+
|
|
63
|
+
attr_reader :client_id
|
|
64
|
+
|
|
65
|
+
# @return [String, nil] the model the worker's turns run on, as the
|
|
66
|
+
# last command said
|
|
67
|
+
attr_reader :model_name
|
|
68
|
+
|
|
69
|
+
# @return [QuestionPrompt, nil] the question waiting for an answer
|
|
70
|
+
attr_reader :question
|
|
71
|
+
|
|
72
|
+
# @param client [BridgeClient]
|
|
73
|
+
# @param screen [Surface] with #synchronize and #columns (a Screen, or a
|
|
74
|
+
# PlainSurface when the terminal can't show a live region)
|
|
75
|
+
# @param client_id [String] this UI's id in the events ("tui:<pid>")
|
|
76
|
+
# @param first_prompt [String, nil] sent once joined (`chi -p`)
|
|
77
|
+
# @param first_command [String, nil] run before the first prompt
|
|
78
|
+
# (`--model` on a resumed session: "/model X"); if it doesn't go
|
|
79
|
+
# through, the launch stops
|
|
80
|
+
# @param no_interrupt [Boolean] post every turn with no_interrupt
|
|
81
|
+
# @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
|
|
82
|
+
# the first read (a new session with no -p, as the REPL)
|
|
83
|
+
# @param clock [#call] monotonic seconds (the Ctrl-C detach window)
|
|
84
|
+
# @param delete_session [#call] session id -> deletes it (/exit --delete)
|
|
85
|
+
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
86
|
+
default_input: false, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
87
|
+
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) })
|
|
88
|
+
@client = client
|
|
89
|
+
@delete_session = delete_session
|
|
90
|
+
@screen = screen
|
|
91
|
+
@client_id = client_id
|
|
92
|
+
@view = AttachedView.new(screen)
|
|
93
|
+
@renderer = EventRenderer.new(@view)
|
|
94
|
+
@shown_enqueued = Set.new
|
|
95
|
+
@running = false
|
|
96
|
+
@joined_mid_turn = false
|
|
97
|
+
@attached = false
|
|
98
|
+
@question = nil
|
|
99
|
+
@answered_ids = Set.new
|
|
100
|
+
@reader = nil
|
|
101
|
+
@first_prompt = first_prompt
|
|
102
|
+
@first_command = first_command
|
|
103
|
+
@first_command_id = nil
|
|
104
|
+
@no_interrupt = no_interrupt
|
|
105
|
+
@no_default_input = !default_input
|
|
106
|
+
@clock = clock
|
|
107
|
+
@last_idle_interrupt_at = nil
|
|
108
|
+
@next_input_prefill = nil
|
|
109
|
+
# Prompts this run sent, by enqueued_id: only those come back into
|
|
110
|
+
# the input when their turn fails (a replayed event must not).
|
|
111
|
+
@sent_ids = Set.new
|
|
112
|
+
# A continue offer is pending: the prompt asks for the answer.
|
|
113
|
+
@continue_offer = nil
|
|
114
|
+
@model_name = nil
|
|
115
|
+
@turn_continues = false
|
|
116
|
+
# The idle status line's data (the REPL's segments).
|
|
117
|
+
@memory_names = []
|
|
118
|
+
# The session's --memory list (shown with the used memories until the
|
|
119
|
+
# first turn records them) and its --mute list, from the join.
|
|
120
|
+
@preloaded_names = []
|
|
121
|
+
@muted_names = []
|
|
122
|
+
# The session that delegated this one, shown as ↳ <short id>.
|
|
123
|
+
@parent_id = nil
|
|
124
|
+
@context_estimate = nil
|
|
125
|
+
@status_rows = nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def running? = @running
|
|
129
|
+
|
|
130
|
+
# Follow the session and read input until Ctrl-D, /detach, /exit or
|
|
131
|
+
# the worker goes away. The worker keeps running after a detach; /exit
|
|
132
|
+
# asks it to exit too, and it does unless something still needs it.
|
|
133
|
+
# @return [Symbol] :detached, :closed when the worker went away, or
|
|
134
|
+
# :failed when the first command (--model) didn't go through
|
|
135
|
+
# @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
|
|
136
|
+
# Interrupt = Ctrl-C); defaults to Reline
|
|
137
|
+
def run(input: nil)
|
|
138
|
+
queue = @queue = Thread::Queue.new
|
|
139
|
+
load_persistent_history
|
|
140
|
+
# Reline's seam asks this on Ctrl-C while the typed text is still
|
|
141
|
+
# there; declining lets the read end with Interrupt as before.
|
|
142
|
+
previous_interrupt_handler = RelineSeam.interrupt_handler
|
|
143
|
+
RelineSeam.interrupt_handler = method(:note_interrupted_line)
|
|
144
|
+
# Tagged, so the worker doesn't count this stream as another UI
|
|
145
|
+
# when this UI asks it to exit.
|
|
146
|
+
stream = @client.follow(client_id: @client_id) { |event| queue << [:event, event] }
|
|
147
|
+
@reader = LineReader.new(queue, prompt: method(:prompt_text), read: input || method(:read_input_line),
|
|
148
|
+
prefill: default_input_text).start
|
|
149
|
+
loop do
|
|
150
|
+
kind, payload = next_item(queue)
|
|
151
|
+
case kind
|
|
152
|
+
when :event
|
|
153
|
+
ended = safely_handle(payload)
|
|
154
|
+
return ended if ended == :closed || ended == :failed
|
|
155
|
+
when :line then return :detached if submit(payload) == :detach
|
|
156
|
+
when :interrupt then return :detached if interrupt(payload) == :detach
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
ensure
|
|
160
|
+
RelineSeam.interrupt_handler = previous_interrupt_handler
|
|
161
|
+
# The loop is over: end the read the reader still has open. Not under
|
|
162
|
+
# the screen's lock: the reader may be waiting for it to draw. The
|
|
163
|
+
# read takes its prompt away as it ends; clearing the editor slot
|
|
164
|
+
# covers a reader that had to be killed.
|
|
165
|
+
@reader&.stop
|
|
166
|
+
@view.stop
|
|
167
|
+
@screen.clear_slot(:editor)
|
|
168
|
+
stream&.close
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Render one Bridge event (string keys).
|
|
172
|
+
# @return [Symbol, nil] :closed when the stream ended, :failed when the
|
|
173
|
+
# launch's first command didn't go through
|
|
174
|
+
def handle_event(event)
|
|
175
|
+
event = EventRenderer.symbolize(event)
|
|
176
|
+
case event[:type]
|
|
177
|
+
when :snapshot, :reset
|
|
178
|
+
take_session_state(event[:session_state_snapshot] || {})
|
|
179
|
+
return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
|
|
180
|
+
when :turn_enqueued then show_enqueued(event)
|
|
181
|
+
when :turn_started then start_turn(event)
|
|
182
|
+
when :turn_completed then complete_turn(event)
|
|
183
|
+
when :turn_canceled
|
|
184
|
+
continued = @turn_continues
|
|
185
|
+
end_turn("turn cancelled (#{event[:cancellation_reason]})")
|
|
186
|
+
# A cancelled continue is back where it started; a prompt turn's
|
|
187
|
+
# partial progress stays, as in the REPL.
|
|
188
|
+
@screen.commit(ROLLBACK_HINT) unless continued
|
|
189
|
+
when :turn_failed
|
|
190
|
+
end_turn("turn failed: #{event[:summary] || "#{event[:message]} (#{event[:error_class]})"}")
|
|
191
|
+
when :prompt_restored then restore_prompt(event)
|
|
192
|
+
when :context_status
|
|
193
|
+
@context_estimate = { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] }
|
|
194
|
+
refresh_status
|
|
195
|
+
when :used_memories_updated
|
|
196
|
+
@memory_names = Array(event[:used_memory_names])
|
|
197
|
+
refresh_status
|
|
198
|
+
# Another client's anytime command: its line now, before the cards
|
|
199
|
+
# it shows (its command_ran comes when it's done).
|
|
200
|
+
when :command_queued
|
|
201
|
+
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id])
|
|
202
|
+
when :command_ran
|
|
203
|
+
command_ran(event)
|
|
204
|
+
return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
|
|
205
|
+
when :reminder_injected then @screen.commit(reminder_line(event[:reminders]))
|
|
206
|
+
# A context note joined the conversation (between turns). One dim
|
|
207
|
+
# line in the scrollback; the notes slot is for question choices.
|
|
208
|
+
when :context_added then @screen.commit(context_note_line(event[:label], event[:text]))
|
|
209
|
+
when :continue_offered then offer_continue(event)
|
|
210
|
+
when :continue_resolved then continue_resolved(event)
|
|
211
|
+
# The note comes with the kernel's :pending_input_merged (EventRenderer),
|
|
212
|
+
# after the answer the merge follows.
|
|
213
|
+
when :input_merged then nil
|
|
214
|
+
when :question_requested then ask(event[:pending_question])
|
|
215
|
+
when :question_answered then question_answered(event)
|
|
216
|
+
when :question_cancelled
|
|
217
|
+
close_question("(question cancelled)") if @question
|
|
218
|
+
# Written while the session sat idle: shown at the open prompt. One
|
|
219
|
+
# collected just as a turn started describes the chat before it.
|
|
220
|
+
when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
|
|
221
|
+
when :guardrail_warning then @screen.commit(EventRenderer.load_warning_line(event))
|
|
222
|
+
# A plugin's slow setup: the activity row turns while it runs; a
|
|
223
|
+
# line when it is done (a failure is a warn card).
|
|
224
|
+
when :plugin_init_started then @view.init_started(event)
|
|
225
|
+
when :plugin_init_finished
|
|
226
|
+
@view.init_finished(event)
|
|
227
|
+
line = EventRenderer.init_line(event)
|
|
228
|
+
@screen.commit(line) if line
|
|
229
|
+
when :generation_completed
|
|
230
|
+
take_served_model(event[:served_model], event[:requested_model])
|
|
231
|
+
@renderer.call(event)
|
|
232
|
+
when :stream_closed
|
|
233
|
+
@view.finish_thinking_spinner
|
|
234
|
+
@screen.commit("Lost the session's worker (#{event[:reason]}). " \
|
|
235
|
+
"Resume it with: chi --shared --resume #{@client.session_id}")
|
|
236
|
+
return :closed
|
|
237
|
+
else @renderer.call(event)
|
|
238
|
+
end
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def next_item(queue)
|
|
245
|
+
queue.pop
|
|
246
|
+
rescue Interrupt
|
|
247
|
+
# Ctrl-C while no prompt is open (between two reads).
|
|
248
|
+
[:interrupt]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# A rendering bug must not end the session's UI.
|
|
252
|
+
def safely_handle(event)
|
|
253
|
+
handle_event(event)
|
|
254
|
+
rescue StandardError => e
|
|
255
|
+
@screen.commit("(could not render #{event["type"] || event[:type]}: #{e.class}: #{e.message})")
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# @return [Symbol, nil] :detach to end the loop
|
|
260
|
+
def submit(line)
|
|
261
|
+
return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
|
|
262
|
+
|
|
263
|
+
text = line.strip
|
|
264
|
+
return answer_question(text) if @question
|
|
265
|
+
# Before the continue offer: neither is an answer to it.
|
|
266
|
+
return submit(nil) if DETACH_COMMANDS.include?(text.downcase)
|
|
267
|
+
return exit_worker if exit_command?(text)
|
|
268
|
+
return exit_and_delete if exit_command?(text.delete_suffix(DELETE_FLAG).rstrip) && text.end_with?(" #{DELETE_FLAG}")
|
|
269
|
+
command = text.split(/\s+/, 2).first
|
|
270
|
+
if @continue_offer
|
|
271
|
+
return answer_continue(text) unless command_registry.command?(text) || [STATS_COMMAND, RECAP_COMMAND].include?(command)
|
|
272
|
+
|
|
273
|
+
# Not an answer: the read left no echo, so show what ran.
|
|
274
|
+
echo_answer(text)
|
|
275
|
+
end
|
|
276
|
+
return if text.empty?
|
|
277
|
+
|
|
278
|
+
if command == STATS_COMMAND
|
|
279
|
+
show_stats
|
|
280
|
+
elsif command == RECAP_COMMAND
|
|
281
|
+
show_recap
|
|
282
|
+
elsif command_registry.command?(text)
|
|
283
|
+
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
284
|
+
persist_recent_history(text) if shell_line?(text)
|
|
285
|
+
send_command(text)
|
|
286
|
+
else
|
|
287
|
+
send_prompt(text)
|
|
288
|
+
end
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# @return [Symbol] :detach
|
|
293
|
+
def detach(line)
|
|
294
|
+
@view.finish_thinking_spinner
|
|
295
|
+
@screen.commit(line)
|
|
296
|
+
:detach
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Detach, and ask the worker to exit now. The worker decides: it stays
|
|
300
|
+
# up while anything still needs it (a turn, queued input, a continue
|
|
301
|
+
# offer, another UI, reminders), and the line says which.
|
|
302
|
+
# @return [Symbol] :detach
|
|
303
|
+
def exit_worker
|
|
304
|
+
detach(exit_line(@client.request_exit(client_id: @client_id)))
|
|
305
|
+
rescue SystemCallError, IOError => e
|
|
306
|
+
detach(exit_failed_line(e.message))
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
|
|
310
|
+
|
|
311
|
+
# /exit --delete: the worker exits as for /exit, then the session is
|
|
312
|
+
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
313
|
+
# nothing is deleted.
|
|
314
|
+
def exit_and_delete
|
|
315
|
+
reply = @client.request_exit(client_id: @client_id, delete: true)
|
|
316
|
+
id = @client.session_id
|
|
317
|
+
unless reply.status == 200
|
|
318
|
+
line = exit_line(reply)
|
|
319
|
+
return detach(line.sub("Detached; the session keeps running", "Detached; not deleted: the session keeps running")) if reply.status == 409
|
|
320
|
+
|
|
321
|
+
return detach("#{line} Not deleted.")
|
|
322
|
+
end
|
|
323
|
+
# An empty session: the worker deletes it as it leaves.
|
|
324
|
+
@delete_session.call(id) unless discards?(reply)
|
|
325
|
+
detach("Detached; deleted session #{id}.")
|
|
326
|
+
rescue SystemCallError, IOError => e
|
|
327
|
+
detach("#{exit_failed_line(e.message)} Not deleted.")
|
|
328
|
+
rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError => e
|
|
329
|
+
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def exit_line(reply)
|
|
333
|
+
id = @client.session_id
|
|
334
|
+
error = reply.json&.fetch("error", nil)
|
|
335
|
+
case reply.status
|
|
336
|
+
when 200
|
|
337
|
+
# Said as the worker agreed to leave; a note coming in before it
|
|
338
|
+
# does keeps the session after all (rare, left as is).
|
|
339
|
+
return "Detached; the session was empty, so it is discarded." if discards?(reply)
|
|
340
|
+
|
|
341
|
+
"Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
|
|
342
|
+
when 409
|
|
343
|
+
reason = reply.json&.fetch("reason", nil).to_s
|
|
344
|
+
"Detached; the session keeps running (#{HELD_REASONS.fetch(reason, reason)}). Re-attach with: chi --attach #{id}"
|
|
345
|
+
when 0 then exit_failed_line("no reply")
|
|
346
|
+
else
|
|
347
|
+
# A worker from before the route answers the Bridge's plain 404.
|
|
348
|
+
return "Detached; this worker runs an older chi and can't be stopped from here: chi sessions stop #{id}" if error == "not_found"
|
|
349
|
+
|
|
350
|
+
exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "))
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# An older worker leaves the field out.
|
|
355
|
+
def discards?(reply) = reply.json&.fetch("discard", nil) == true
|
|
356
|
+
|
|
357
|
+
def exit_failed_line(why)
|
|
358
|
+
"Detached (could not ask the worker to stop: #{why}). Re-attach with: chi --attach #{@client.session_id}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# The worker says what became of it (:continue_resolved); an invalid
|
|
362
|
+
# answer gets its error and the offer stays.
|
|
363
|
+
def answer_continue(text)
|
|
364
|
+
echo_answer(text) if TurnFlow.continue_decision(text).first == :invalid
|
|
365
|
+
@continue_answer = text.empty? ? "yes" : text
|
|
366
|
+
send_command(continue_line(text))
|
|
367
|
+
nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# A bare answer at the continue prompt, as the REPL reads it (an empty
|
|
371
|
+
# one is yes).
|
|
372
|
+
def continue_line(text)
|
|
373
|
+
text.empty? ? SessionCommands::CONTINUE_COMMAND : "#{SessionCommands::CONTINUE_COMMAND} #{text}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# The worker runs it and every UI renders its :command_ran, this one
|
|
377
|
+
# too, so nothing waits here.
|
|
378
|
+
def send_command(line)
|
|
379
|
+
reply = @client.post_command(line: line, client_id: @client_id)
|
|
380
|
+
return if reply.status == 202
|
|
381
|
+
return @screen.commit(stale_worker("run commands")) if reply.status == 404
|
|
382
|
+
return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
|
|
383
|
+
|
|
384
|
+
detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
|
|
385
|
+
@screen.commit("could not run the command (#{[reply.status, detail].compact.join(" ")})")
|
|
386
|
+
rescue SystemCallError, IOError => e
|
|
387
|
+
@screen.commit("could not run the command (#{worker_down(e)})")
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# A request the Bridge read after its deadline and dropped
|
|
391
|
+
# (BridgeClient::DEADLINE_SHARE): it didn't go through.
|
|
392
|
+
def too_late?(reply) = reply.status == 408 && reply.json&.fetch("error", nil) == "deadline_passed"
|
|
393
|
+
|
|
394
|
+
# Why a Bridge request raised, for the line that says what didn't go
|
|
395
|
+
# through: a stopped (or wedged) worker times out, a gone one refuses.
|
|
396
|
+
def worker_down(error)
|
|
397
|
+
head, detail = error.message.split(" - ", 2)
|
|
398
|
+
return "worker not answering: #{(detail || head).sub(/\Abridge \S+: /, "")}" if error.is_a?(Errno::ETIMEDOUT)
|
|
399
|
+
|
|
400
|
+
"worker unreachable: #{head.downcase}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def command_ran(event)
|
|
404
|
+
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime]
|
|
405
|
+
output = event[:output].to_s
|
|
406
|
+
if output.empty?
|
|
407
|
+
nil
|
|
408
|
+
elsif event[:status] == "busy" || shell_line?(event[:line])
|
|
409
|
+
@screen.commit(output)
|
|
410
|
+
put_back_busy_command(event) if event[:status] == "busy"
|
|
411
|
+
else
|
|
412
|
+
@screen.commit("#{paint("model>", 36)} #{output}")
|
|
413
|
+
end
|
|
414
|
+
return unless event[:model_name]
|
|
415
|
+
|
|
416
|
+
# After the output, so the status row changes with the line saying why.
|
|
417
|
+
@served_model = nil if @model_name != event[:model_name]
|
|
418
|
+
@model_name = event[:model_name]
|
|
419
|
+
refresh_status
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Our command the worker couldn't run yet goes back into the prompt, for
|
|
423
|
+
# Enter once the turn ends (not the launch's --model: that ends the launch).
|
|
424
|
+
def put_back_busy_command(event)
|
|
425
|
+
return unless own?(event[:client_id]) && event[:command_id] != @first_command_id
|
|
426
|
+
|
|
427
|
+
@screen.commit("(the command is in the input history: ↑)") unless @reader&.prefill(event[:line].to_s)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def shell_line?(line)
|
|
431
|
+
line.to_s.start_with?(SessionCommands::SHELL_BANG_PREFIX) && line.to_s.strip != SessionCommands::ROLLBACK_COMMAND
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def offer_continue(event)
|
|
435
|
+
@continue_offer = { context: event[:context], no_interrupt: event[:no_interrupt] }
|
|
436
|
+
sync_prompt
|
|
437
|
+
sync_continue_slot
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
CONTINUE_DECISIONS = { "resume" => "yes", "abort" => "no", "abort_with_reason" => "no, with a reason" }.freeze
|
|
441
|
+
|
|
442
|
+
# The offer's choices go; one line says what became of it. Another
|
|
443
|
+
# UI's answer shows as its command line (web> /continue yes) instead.
|
|
444
|
+
def continue_resolved(event)
|
|
445
|
+
@continue_offer = nil
|
|
446
|
+
sync_continue_slot
|
|
447
|
+
who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
|
|
448
|
+
outcome = if event[:decision] == "dropped"
|
|
449
|
+
"(dropped: #{own?(event[:client_id]) ? "you" : who} sent a new prompt)"
|
|
450
|
+
elsif own?(event[:client_id])
|
|
451
|
+
@continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
|
|
452
|
+
end
|
|
453
|
+
@continue_answer = nil
|
|
454
|
+
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
|
|
455
|
+
sync_prompt
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# The notes slot shows the offer's choices while it waits (a question
|
|
459
|
+
# has the slot while it is open).
|
|
460
|
+
def sync_continue_slot
|
|
461
|
+
return if @question
|
|
462
|
+
|
|
463
|
+
if @continue_offer
|
|
464
|
+
@screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
|
|
465
|
+
else
|
|
466
|
+
@screen.clear_slot(:notes)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def send_prompt(text)
|
|
471
|
+
persist_recent_history(text)
|
|
472
|
+
# #memory shorthand becomes words the model reads, as in the REPL.
|
|
473
|
+
prompt = normalize_model_input(text)
|
|
474
|
+
images = attach_images(text)
|
|
475
|
+
return if images.nil?
|
|
476
|
+
|
|
477
|
+
options = { prompt: prompt, client_id: @client_id }
|
|
478
|
+
options[:no_interrupt] = true if @no_interrupt
|
|
479
|
+
options[:images] = images unless images.empty?
|
|
480
|
+
reply = @client.post_turn(**options)
|
|
481
|
+
if reply.status == 202
|
|
482
|
+
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
483
|
+
@sent_ids << enqueued_id if enqueued_id
|
|
484
|
+
return
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
detail = reply.json&.fetch("error", nil)
|
|
488
|
+
# Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
|
|
489
|
+
return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
|
|
490
|
+
|
|
491
|
+
explained = reply.json&.fetch("detail", nil) if detail == "images_unsupported"
|
|
492
|
+
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
|
|
493
|
+
rescue SystemCallError, IOError => e
|
|
494
|
+
@screen.commit("could not send the prompt (#{worker_down(e)})")
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# The prompt's `@path` images, stored in the session's images/ here
|
|
498
|
+
# (this machine, the worker's state dir) and sent as refs: the Bridge
|
|
499
|
+
# never takes a path. nil (after a line saying why) when one can't be.
|
|
500
|
+
def attach_images(text)
|
|
501
|
+
paths = ImageInput.extract(text)
|
|
502
|
+
return [] if paths.empty?
|
|
503
|
+
|
|
504
|
+
session_dir = Session.session_dir(@client.session_id)
|
|
505
|
+
paths.map do |image|
|
|
506
|
+
ref = ImageStore.ingest(session_dir, path: image[:path])
|
|
507
|
+
{ file: ref[:file], name: ref[:name] }
|
|
508
|
+
end
|
|
509
|
+
rescue ImageStore::Error => e
|
|
510
|
+
@screen.commit("could not attach the image: #{e.message}")
|
|
511
|
+
nil
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def show_stats
|
|
515
|
+
# A worker from before the stats route answers only /state.
|
|
516
|
+
metrics = @client.get_json("stats")&.dig("metrics") || @client.get_json("state")&.dig("session_state_snapshot", "metrics")
|
|
517
|
+
@screen.commit(metrics ? format_session_metrics(EventRenderer.deep_symbolize_keys(metrics)) : "(no metrics: the worker did not answer)")
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# One read on the reader thread: the REPL's multiline read with Tab
|
|
521
|
+
# completion at the main prompt, a plain line for a question's choice
|
|
522
|
+
# or the continue offer's answer.
|
|
523
|
+
def read_input_line(prompt, prefill)
|
|
524
|
+
return $stdin.gets&.chomp unless $stdin.tty?
|
|
525
|
+
# A question's answer leaves only its summary line.
|
|
526
|
+
return RelineSeam.without_echo { Reline.readline(prompt, true) } if @question || @continue_offer
|
|
527
|
+
|
|
528
|
+
queue_input_prefill(prefill) if prefill
|
|
529
|
+
read_prompt_line(prompt)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# The attached TUI's commands, for Tab.
|
|
533
|
+
def slash_commands = command_registry.completions(:attached)
|
|
534
|
+
|
|
535
|
+
# The session's commands as its worker's snapshot names them (its
|
|
536
|
+
# plugins' too); the built-ins until one arrives, or from a worker too
|
|
537
|
+
# old to name them. An unknown /foo is a prompt, as in the REPL.
|
|
538
|
+
def command_registry = @command_registry || SessionCommands.builtin_registry
|
|
539
|
+
|
|
540
|
+
# The saved recap, and a new one asked for at once (it arrives as
|
|
541
|
+
# :recap_ready), with the REPL's words.
|
|
542
|
+
def show_recap
|
|
543
|
+
reply = @client.request_recap
|
|
544
|
+
answer = reply.status == 200 ? reply.json : nil
|
|
545
|
+
return @screen.commit("(no recap: the worker did not answer)") unless answer
|
|
546
|
+
|
|
547
|
+
@screen.commit(recap_command_text(enabled: answer["enabled"], saved: answer["saved"],
|
|
548
|
+
request: answer["request"], min_user_turns: answer["min_user_turns"]))
|
|
549
|
+
rescue SystemCallError, IOError
|
|
550
|
+
@screen.commit("(no recap: the worker did not answer)")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def prompt_text
|
|
554
|
+
return paint(QUESTION_PROMPT, 33) if @question || @continue_offer
|
|
555
|
+
|
|
556
|
+
paint(PROMPT, 92)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Switch the open prompt to ? for the answer and show the choices in
|
|
560
|
+
# the notes slot, fitted to the terminal. Once the question closes,
|
|
561
|
+
# one line (the question and what became of it) stays.
|
|
562
|
+
def ask(pending)
|
|
563
|
+
return unless pending
|
|
564
|
+
|
|
565
|
+
@view.finish_thinking_spinner
|
|
566
|
+
# What was typed at the prompt waits for the question to close.
|
|
567
|
+
@set_aside = @reader&.typed_text unless @question
|
|
568
|
+
@question = QuestionPrompt.new(pending)
|
|
569
|
+
# The prompt first, so the choices never show under the typed text.
|
|
570
|
+
sync_prompt(keep_text: false)
|
|
571
|
+
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def answer_question(text)
|
|
575
|
+
return dismiss_question if text.empty?
|
|
576
|
+
|
|
577
|
+
answer = @question.parse(text)
|
|
578
|
+
@screen.commit(answer.note) if answer.note
|
|
579
|
+
unless answer.ok?
|
|
580
|
+
echo_answer(text)
|
|
581
|
+
return @screen.commit(answer.error)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform)
|
|
585
|
+
case reply.status
|
|
586
|
+
when 200
|
|
587
|
+
@answered_ids << @question.id
|
|
588
|
+
close_question(@question.answer_text(answer))
|
|
589
|
+
when 409 then close_question("(already answered in another UI)")
|
|
590
|
+
when 408 then @screen.commit("could not answer (#{LATE})")
|
|
591
|
+
else
|
|
592
|
+
detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
|
|
593
|
+
@screen.commit("could not answer (#{[reply.status, detail].compact.join(" ")})")
|
|
594
|
+
end
|
|
595
|
+
nil
|
|
596
|
+
rescue SystemCallError, IOError => e
|
|
597
|
+
@screen.commit("could not answer (#{worker_down(e)})")
|
|
598
|
+
nil
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# An empty answer dismisses the question, as in the REPL: the tool
|
|
602
|
+
# returns unanswered (an approval: denied) and the turn goes on. The
|
|
603
|
+
# :question_cancelled every UI gets finds it already closed here.
|
|
604
|
+
def dismiss_question
|
|
605
|
+
reply = @client.dismiss_question(id: @question.id)
|
|
606
|
+
case reply.status
|
|
607
|
+
when 200 then close_question(@question.approval? ? "(denied)" : "(cancelled)")
|
|
608
|
+
when 409 then close_question("(question already closed in another UI)")
|
|
609
|
+
when 404 then @screen.commit("#{stale_worker("dismiss questions")}; Ctrl-C cancels the turn")
|
|
610
|
+
when 408 then @screen.commit("could not dismiss the question (#{LATE}); Ctrl-C cancels the turn")
|
|
611
|
+
else
|
|
612
|
+
# The question stays open (after a 404 too).
|
|
613
|
+
detail = reply.json&.fetch("error", nil)
|
|
614
|
+
@screen.commit("could not dismiss the question (#{[reply.status, detail].compact.join(" ")}); Ctrl-C cancels the turn")
|
|
615
|
+
end
|
|
616
|
+
nil
|
|
617
|
+
rescue SystemCallError, IOError => e
|
|
618
|
+
@screen.commit("could not dismiss the question (#{worker_down(e)}); Ctrl-C cancels the turn")
|
|
619
|
+
nil
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# A line read at ? that isn't a (valid) answer: the read left no echo,
|
|
623
|
+
# so it shows above what it got.
|
|
624
|
+
def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
|
|
625
|
+
|
|
626
|
+
def stale_worker(cant)
|
|
627
|
+
BridgeClient.stale_worker_message(@client.session_id, cant: cant)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# The worker rolled a failed turn back and handed its prompt back: ours
|
|
631
|
+
# goes back into the input, as the REPL restores it for a retry.
|
|
632
|
+
def restore_prompt(event)
|
|
633
|
+
origin = event[:origin] || {}
|
|
634
|
+
return unless own?(origin[:client_id]) && @sent_ids.include?(origin[:enqueued_id])
|
|
635
|
+
|
|
636
|
+
if @reader&.prefill(event[:prompt].to_s)
|
|
637
|
+
@screen.commit("(prompt restored for retry)")
|
|
638
|
+
else
|
|
639
|
+
@screen.commit("(the failed prompt is in the input history: ↑)")
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def question_answered(event)
|
|
644
|
+
return unless @question
|
|
645
|
+
return close_question(nil) if @answered_ids.include?(event[:id])
|
|
646
|
+
|
|
647
|
+
answer = event[:answer] || {}
|
|
648
|
+
picked = [*Array(answer[:selected]), answer[:freeform]].compact.join(", ")
|
|
649
|
+
close_question("#{picked.empty? ? "(answered)" : picked} (in another UI)")
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# @param outcome [String, nil] what became of it, for the line that
|
|
653
|
+
# stays in the scrollback (nil: none, it was closed already)
|
|
654
|
+
def close_question(outcome)
|
|
655
|
+
@screen.clear_slot(:notes)
|
|
656
|
+
@screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
|
|
657
|
+
@question = nil
|
|
658
|
+
set_aside = @set_aside
|
|
659
|
+
@set_aside = nil
|
|
660
|
+
sync_prompt(keep_text: false, prefill: set_aside)
|
|
661
|
+
sync_continue_slot if @continue_offer
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Restart the open read when its prompt no longer fits (a question or a
|
|
665
|
+
# continue offer opened or closed), first erasing the prompt Reline drew.
|
|
666
|
+
# What is typed there goes along (+keep_text+), or +prefill+ goes in.
|
|
667
|
+
def sync_prompt(keep_text: true, prefill: nil)
|
|
668
|
+
return unless @reader
|
|
669
|
+
|
|
670
|
+
@screen.synchronize do
|
|
671
|
+
next if @reader.current == prompt_text
|
|
672
|
+
|
|
673
|
+
@screen.clear_slot(:editor)
|
|
674
|
+
@reader.reprompt(keep_text: keep_text, prefill: prefill)
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Ctrl-C cancels the running turn (whoever started it). At an idle
|
|
679
|
+
# prompt it clears the line; on an empty one it says how to detach, and
|
|
680
|
+
# a second press within DETACH_WINDOW detaches (D4).
|
|
681
|
+
# @param pressed [Hash, nil] {text:, at:} from the read the press ended
|
|
682
|
+
# (nil: pressed with no read open)
|
|
683
|
+
# @return [Symbol, nil] :detach
|
|
684
|
+
def interrupt(pressed = nil)
|
|
685
|
+
typed = pressed&.fetch(:text, nil).to_s
|
|
686
|
+
if @running
|
|
687
|
+
begin
|
|
688
|
+
@client.cancel(reason: "ctrl_c")
|
|
689
|
+
rescue SystemCallError, IOError => e
|
|
690
|
+
@screen.commit("could not cancel the turn (#{worker_down(e)})")
|
|
691
|
+
end
|
|
692
|
+
return nil
|
|
693
|
+
end
|
|
694
|
+
unless typed.strip.empty?
|
|
695
|
+
@last_idle_interrupt_at = nil
|
|
696
|
+
return nil
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
now = pressed&.fetch(:at, nil) || @clock.call
|
|
700
|
+
return submit(nil) if @last_idle_interrupt_at && now - @last_idle_interrupt_at <= DETACH_WINDOW
|
|
701
|
+
|
|
702
|
+
@last_idle_interrupt_at = now
|
|
703
|
+
@screen.commit(DETACH_HINT)
|
|
704
|
+
nil
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# On the reader thread, from Reline's Ctrl-C: what the line held, and
|
|
708
|
+
# when. During a turn the press goes to the loop (it cancels the turn)
|
|
709
|
+
# and the read goes on with the typed text still in it, as in the REPL;
|
|
710
|
+
# otherwise it is noted for the Interrupt the LineReader queues next.
|
|
711
|
+
# @return [Boolean] true: handled, the read goes on; false: Reline ends
|
|
712
|
+
# the read with Interrupt
|
|
713
|
+
def note_interrupted_line
|
|
714
|
+
pressed = { text: Reline.line_buffer.to_s, at: @clock.call }
|
|
715
|
+
if @running
|
|
716
|
+
@queue << [:interrupt, pressed]
|
|
717
|
+
return true
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
Thread.current[:interrupted_line] = pressed
|
|
721
|
+
false
|
|
722
|
+
rescue StandardError
|
|
723
|
+
false
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def render_snapshot(snapshot, reset:)
|
|
727
|
+
@view.finish_thinking_spinner
|
|
728
|
+
if @attached
|
|
729
|
+
@screen.commit("(resynced with the session)") if reset
|
|
730
|
+
else
|
|
731
|
+
@attached = true
|
|
732
|
+
# The recap first: what the session was about, then where it stopped.
|
|
733
|
+
saved = snapshot[:saved_recap]
|
|
734
|
+
@screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved && !saved[:text].to_s.strip.empty?
|
|
735
|
+
render_join_header(Array(snapshot[:messages]))
|
|
736
|
+
@screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
|
|
737
|
+
@screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
|
|
738
|
+
end
|
|
739
|
+
if snapshot[:commands]
|
|
740
|
+
@command_registry = Commands::Registry.from_listing(snapshot[:commands], base: SessionCommands.builtin_registry)
|
|
741
|
+
end
|
|
742
|
+
Array(snapshot[:init_tasks]).each { |task| @view.init_started(task) }
|
|
743
|
+
cards = Array(snapshot[:cards])
|
|
744
|
+
render_snapshot_cards(cards.reject { |card| card[:current] }, joining: !reset)
|
|
745
|
+
offer = snapshot[:continue_offer]
|
|
746
|
+
had_offer = !@continue_offer.nil?
|
|
747
|
+
@continue_offer = offer && { context: offer[:context], no_interrupt: offer[:no_interrupt] }
|
|
748
|
+
if had_offer != !@continue_offer.nil?
|
|
749
|
+
sync_prompt
|
|
750
|
+
sync_continue_slot
|
|
751
|
+
end
|
|
752
|
+
render_current_turn(snapshot[:current_turn])
|
|
753
|
+
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
754
|
+
Array(snapshot[:queued]).each do |entry|
|
|
755
|
+
next if own?(entry[:client_id])
|
|
756
|
+
|
|
757
|
+
@shown_enqueued << entry[:enqueued_id]
|
|
758
|
+
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
|
|
759
|
+
end
|
|
760
|
+
return send_first_command if @first_command
|
|
761
|
+
|
|
762
|
+
send_first_prompt
|
|
763
|
+
nil
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# --model on a resumed session: switch its worker before the first
|
|
767
|
+
# prompt goes; its :command_ran decides (#first_command_ran).
|
|
768
|
+
# @return [Symbol, nil] :failed when the worker didn't take it
|
|
769
|
+
def send_first_command
|
|
770
|
+
line = @first_command
|
|
771
|
+
@first_command = nil
|
|
772
|
+
reply = @client.post_command(line: line, client_id: @client_id)
|
|
773
|
+
if reply.status == 202
|
|
774
|
+
@first_command_id = reply.json&.fetch("command_id", nil)
|
|
775
|
+
return nil if @first_command_id
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
why =
|
|
779
|
+
if reply.status == 404 then stale_worker("run commands")
|
|
780
|
+
elsif too_late?(reply) then LATE
|
|
781
|
+
else "the worker answered #{reply.status}"
|
|
782
|
+
end
|
|
783
|
+
@screen.commit("could not switch to the --model: #{why}")
|
|
784
|
+
:failed
|
|
785
|
+
rescue SystemCallError, IOError => e
|
|
786
|
+
@screen.commit("could not switch to the --model: #{worker_down(e)}")
|
|
787
|
+
:failed
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# @return [Symbol, nil] :failed unless the switch went through
|
|
791
|
+
def first_command_ran(event)
|
|
792
|
+
@first_command_id = nil
|
|
793
|
+
if event[:status] == "ok"
|
|
794
|
+
send_first_prompt
|
|
795
|
+
return nil
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
@screen.commit("could not switch to the --model: #{event[:output]}")
|
|
799
|
+
:failed
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# After the join, so it lands below what the session already had. Our
|
|
803
|
+
# own prompts are never echoed from events (a typed one stays on screen),
|
|
804
|
+
# so it is shown here as if typed.
|
|
805
|
+
def send_first_prompt
|
|
806
|
+
text = @first_prompt.to_s.strip
|
|
807
|
+
@first_prompt = nil
|
|
808
|
+
return if text.empty?
|
|
809
|
+
|
|
810
|
+
@screen.commit("#{paint(PROMPT, 92)}#{text}")
|
|
811
|
+
send_prompt(text)
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def render_join_header(messages)
|
|
815
|
+
exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
|
|
816
|
+
# The id stays findable: the detach line and chi sessions list show it.
|
|
817
|
+
Log.session_id = @client.session_id
|
|
818
|
+
Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
|
|
819
|
+
last_user = exchange.rindex { |m| m[:role].to_s == "user" }
|
|
820
|
+
render_join_notes(messages)
|
|
821
|
+
return unless last_user
|
|
822
|
+
|
|
823
|
+
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
824
|
+
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
825
|
+
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
826
|
+
# be only a tool call.
|
|
827
|
+
answer = exchange[(last_user + 1)..].reverse_each
|
|
828
|
+
.map { |m| OutputFormatter.strip_markup(m[:content]) }
|
|
829
|
+
.find { |text| !text.empty? }
|
|
830
|
+
@screen.commit(last_lines(answer)) if answer
|
|
831
|
+
render_join_notes(messages, after_exchange: true)
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# The context notes that came since the last prompt (all of them in a
|
|
835
|
+
# session with none), after the exchange the header shows.
|
|
836
|
+
def render_join_notes(messages, after_exchange: false)
|
|
837
|
+
last_user = messages.rindex { |m| m[:role].to_s == "user" }
|
|
838
|
+
return if after_exchange != !last_user.nil?
|
|
839
|
+
|
|
840
|
+
messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
|
|
841
|
+
@screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# The end of a long answer; the whole of it is in the session.
|
|
846
|
+
def last_lines(text)
|
|
847
|
+
lines = text.split("\n", -1)
|
|
848
|
+
if lines.size > JOIN_ANSWER_LINES
|
|
849
|
+
text = "(… #{lines.size - JOIN_ANSWER_LINES} earlier lines)\n#{lines.last(JOIN_ANSWER_LINES).join("\n")}"
|
|
850
|
+
end
|
|
851
|
+
return text if text.length <= JOIN_ANSWER_CHARS
|
|
852
|
+
|
|
853
|
+
"(… earlier text)\n…#{text[-JOIN_ANSWER_CHARS..]}"
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# The snapshot's cards and between-turns notices (Bridge CardStore):
|
|
857
|
+
# on join the ones since the last turn (and the running turn's), as
|
|
858
|
+
# the join header shows the last exchange; on a resync only cards not
|
|
859
|
+
# shown here yet. A turn's own notices are left out: the running
|
|
860
|
+
# turn's come with its parts, and the join shows no older turn's steps.
|
|
861
|
+
def render_snapshot_cards(entries, joining:)
|
|
862
|
+
entries.each do |entry|
|
|
863
|
+
if entry[:type].to_s == "hook_notice"
|
|
864
|
+
next if entry[:in_turn]
|
|
865
|
+
|
|
866
|
+
@screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
|
|
867
|
+
elsif joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
|
|
868
|
+
@renderer.render_card(entry, updated: entry[:updated] == true)
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def render_current_turn(turn)
|
|
874
|
+
@running = !turn.nil?
|
|
875
|
+
@joined_mid_turn = @running
|
|
876
|
+
return unless turn
|
|
877
|
+
|
|
878
|
+
@turn_continues = turn[:prompt].nil?
|
|
879
|
+
unless @turn_continues && reminder_origin?(turn[:origin] || {})
|
|
880
|
+
@screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
|
|
881
|
+
end
|
|
882
|
+
Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
883
|
+
tail = nil
|
|
884
|
+
lane = :writing
|
|
885
|
+
running_tool = nil
|
|
886
|
+
Array(turn[:parts]).each do |part|
|
|
887
|
+
case part[:kind]
|
|
888
|
+
when "thinking" then tail, lane = part[:text], :thinking
|
|
889
|
+
when "text" then tail, lane = part[:text], :writing
|
|
890
|
+
when "tool"
|
|
891
|
+
if part[:status] == "running"
|
|
892
|
+
running_tool = part[:tool]
|
|
893
|
+
else
|
|
894
|
+
@screen.commit(snapshot_tool_line(part))
|
|
895
|
+
end
|
|
896
|
+
when "input" then @screen.commit("input> #{part[:text]}")
|
|
897
|
+
when "reminder" then @screen.commit(reminder_line(part[:reminders]))
|
|
898
|
+
when "hook_notice" then @screen.commit(EventRenderer.hook_notice_line(part))
|
|
899
|
+
end
|
|
900
|
+
end
|
|
901
|
+
@view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
|
|
902
|
+
ask(turn[:pending_question]) if turn[:pending_question]
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def show_enqueued(event)
|
|
906
|
+
return if own?(event[:client_id])
|
|
907
|
+
|
|
908
|
+
@shown_enqueued << event[:enqueued_id]
|
|
909
|
+
@screen.commit(prompt_line(event[:client_id], event[:prompt]))
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def start_turn(event)
|
|
913
|
+
@running = true
|
|
914
|
+
@joined_mid_turn = false
|
|
915
|
+
@turn_continues = event[:prompt].nil?
|
|
916
|
+
origin = event[:origin] || {}
|
|
917
|
+
if @turn_continues
|
|
918
|
+
# A continue turn (after the offer's yes) has no prompt to show; a
|
|
919
|
+
# reminder turn shows its reminders (:reminder_injected).
|
|
920
|
+
@screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin)
|
|
921
|
+
elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
|
|
922
|
+
@screen.commit(prompt_line(origin[:client_id], event[:prompt]))
|
|
923
|
+
end
|
|
924
|
+
@renderer.call(event)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def complete_turn(event)
|
|
928
|
+
# Tools from before the join were shown from the snapshot; the ones
|
|
929
|
+
# after it streamed. Either way the summary must not list them again.
|
|
930
|
+
if @joined_mid_turn && event[:turn_summary]
|
|
931
|
+
event = event.merge(turn_summary: event[:turn_summary].merge(tool_activity: []))
|
|
932
|
+
end
|
|
933
|
+
@renderer.call(event)
|
|
934
|
+
# The turn's summary carries its last context estimate.
|
|
935
|
+
@context_estimate = @view.context_status if @view.context_status
|
|
936
|
+
refresh_status
|
|
937
|
+
@running = false
|
|
938
|
+
@joined_mid_turn = false
|
|
939
|
+
close_question("(the turn ended)") if @question
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def end_turn(message)
|
|
943
|
+
@view.finish_thinking_spinner
|
|
944
|
+
@screen.commit(message)
|
|
945
|
+
@running = false
|
|
946
|
+
@joined_mid_turn = false
|
|
947
|
+
close_question("(the turn ended)") if @question
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def own?(client_id) = !client_id.nil? && client_id == @client_id
|
|
951
|
+
|
|
952
|
+
# The joining snapshot's session state: the worker's model and the
|
|
953
|
+
# memories the session used.
|
|
954
|
+
def take_session_state(state)
|
|
955
|
+
@model_name = state[:model_name] if state[:model_name]
|
|
956
|
+
@served_model = nil
|
|
957
|
+
take_served_model(state[:served_model], state[:served_model_for], refresh: false)
|
|
958
|
+
@memory_names = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
|
|
959
|
+
@preloaded_names = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
|
|
960
|
+
@muted_names = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
|
|
961
|
+
@parent_id = state[:parent_id] if state.key?(:parent_id)
|
|
962
|
+
# The last turn's ctx, so it shows before this client's first turn.
|
|
963
|
+
@context_estimate = state[:context_status] if state[:context_status].is_a?(Hash)
|
|
964
|
+
refresh_status
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# The REPL's idle status line (model · ctx · memories) in the status
|
|
968
|
+
# row, redrawn when its text changes.
|
|
969
|
+
def refresh_status
|
|
970
|
+
return unless status_line_enabled?
|
|
971
|
+
|
|
972
|
+
served, served_for = @served_model
|
|
973
|
+
segments = [@model_name ? status_model_text(@model_name, default_model_name, served: served, served_for: served_for) : "",
|
|
974
|
+
@parent_id ? "↳ #{@parent_id.to_s[0, 8]}" : "",
|
|
975
|
+
status_context_text(estimate: @context_estimate),
|
|
976
|
+
status_memory_text(@memory_names | @preloaded_names, MEMORY_STICKY_PREVIEW_LIMIT),
|
|
977
|
+
status_memory_text(@muted_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")].reject(&:empty?)
|
|
978
|
+
rows = status_rows(segments, @screen.columns - 1)
|
|
979
|
+
return if rows == @status_rows
|
|
980
|
+
|
|
981
|
+
@status_rows = rows
|
|
982
|
+
rows.empty? ? @screen.clear_slot(:status) : @screen.set_slot(:status, rows)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# What the worker's server said it served for a name (a generation, or
|
|
986
|
+
# the join's session state); dropped when /model switches.
|
|
987
|
+
def take_served_model(served, served_for, refresh: true)
|
|
988
|
+
return unless served
|
|
989
|
+
|
|
990
|
+
@served_model = [served, served_for]
|
|
991
|
+
refresh_status if refresh
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# The config's default model, as the worker's /model names it.
|
|
995
|
+
def default_model_name
|
|
996
|
+
@default_model_name ||= ModelProfile.required_model_name(nil)
|
|
997
|
+
rescue StandardError
|
|
998
|
+
nil
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
|
|
1002
|
+
|
|
1003
|
+
CONTEXT_NOTE_PREVIEW = 60
|
|
1004
|
+
|
|
1005
|
+
# "note from slack: <first line>", cut to one line.
|
|
1006
|
+
def context_note_line(label, text)
|
|
1007
|
+
lines = text.to_s.strip.split("\n")
|
|
1008
|
+
first = lines.first.to_s
|
|
1009
|
+
if first.length > CONTEXT_NOTE_PREVIEW
|
|
1010
|
+
first = "#{first[0, CONTEXT_NOTE_PREVIEW - 1]}…"
|
|
1011
|
+
elsif lines.size > 1
|
|
1012
|
+
first += " …"
|
|
1013
|
+
end
|
|
1014
|
+
paint("note from #{label || "?"}: #{first}", 2)
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
def reminder_line(reminders)
|
|
1018
|
+
names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
|
|
1019
|
+
names.empty? ? "reminder" : "reminder: #{names.join(", ")}"
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
def prompt_line(client_id, prompt)
|
|
1023
|
+
label = client_id ? CLIENT_LABELS.fetch(client_id.to_s.split(":", 2).first, "user") : "user"
|
|
1024
|
+
"#{paint("#{label}>", 35)} #{prompt}"
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# A tool call from the snapshot: it has no action text, only the tool.
|
|
1028
|
+
def snapshot_tool_line(part)
|
|
1029
|
+
params = part[:params].to_s.strip
|
|
1030
|
+
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
1031
|
+
status = part[:status].to_s
|
|
1032
|
+
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status == "ok" ? 32 : 31)}" \
|
|
1033
|
+
"#{format_tool_image_suffix(part[:images])}"
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
end
|