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,1992 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "io/console"
|
|
7
|
+
require "reline"
|
|
8
|
+
|
|
9
|
+
require_relative "model_profile"
|
|
10
|
+
require_relative "turn_note"
|
|
11
|
+
require_relative "config"
|
|
12
|
+
require_relative "context_note"
|
|
13
|
+
require_relative "cancellation_controller"
|
|
14
|
+
require_relative "llm/errors"
|
|
15
|
+
require_relative "host_registry"
|
|
16
|
+
require_relative "context_usage"
|
|
17
|
+
require_relative "context_window"
|
|
18
|
+
require_relative "kernel_loop"
|
|
19
|
+
require_relative "session"
|
|
20
|
+
require_relative "owner_lock"
|
|
21
|
+
require_relative "engine"
|
|
22
|
+
require_relative "tools/memory"
|
|
23
|
+
require_relative "output_formatter"
|
|
24
|
+
require_relative "turn_flow"
|
|
25
|
+
require_relative "turn_tally"
|
|
26
|
+
require_relative "session_commands"
|
|
27
|
+
require_relative "terminal_ui/event_renderer"
|
|
28
|
+
require_relative "terminal_ui/formatting"
|
|
29
|
+
require_relative "terminal_ui/input_support"
|
|
30
|
+
require_relative "terminal_ui/image_input"
|
|
31
|
+
require_relative "terminal_ui/legacy_surface"
|
|
32
|
+
require_relative "terminal_ui/live_region"
|
|
33
|
+
require_relative "terminal_ui/question_prompt"
|
|
34
|
+
require_relative "terminal_ui/repl_input"
|
|
35
|
+
require_relative "terminal_ui/thinking_line"
|
|
36
|
+
require_relative "log"
|
|
37
|
+
|
|
38
|
+
module Samagotchi
|
|
39
|
+
# Loaded on first use: session_manager requires this file (its worker
|
|
40
|
+
# process loads the whole UI stack through it), a require cycle otherwise.
|
|
41
|
+
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
42
|
+
|
|
43
|
+
# TerminalUI encapsulates the single operating mode of the harness.
|
|
44
|
+
#
|
|
45
|
+
# assist mode — interactive REPL: user types, model responds, tools execute inline.
|
|
46
|
+
class TerminalUI
|
|
47
|
+
include Formatting
|
|
48
|
+
include InputSupport
|
|
49
|
+
|
|
50
|
+
AGENT_DESCRIPTION_FILE = "AGENT.md"
|
|
51
|
+
SKIP_AGENT_DESCRIPTION_ENV = "SAMAGOTCHI_SKIP_AGENT_MD"
|
|
52
|
+
STATS_COMMAND = "/stats"
|
|
53
|
+
# /exit --delete: delete the session on the way out.
|
|
54
|
+
EXIT_DELETE_FLAG = "--delete"
|
|
55
|
+
RECAP_COMMAND = "/recap"
|
|
56
|
+
# The prompt while a question waits for its answer (its choices are in
|
|
57
|
+
# the notes slot).
|
|
58
|
+
QUESTION_PROMPT = "? "
|
|
59
|
+
THINKING_UI_ENV = "SAMAGOTCHI_THINKING_UI"
|
|
60
|
+
THINKING_UI_SPINNER = "spinner"
|
|
61
|
+
THINKING_UI_OFF = "off"
|
|
62
|
+
THINKING_SPINNER_FRAMES = ["|", "/", "-", "\\"].freeze
|
|
63
|
+
MEMORY_SPINNER_COLOR = "38;5;208"
|
|
64
|
+
TOOL_SPINNER_COLOR = 32
|
|
65
|
+
NETWORK_RETRY_SPINNER_COLOR = 31
|
|
66
|
+
MEMORY_SPINNER_PREVIEW_LIMIT = 3
|
|
67
|
+
MEMORY_STICKY_PREVIEW_LIMIT = 8
|
|
68
|
+
THINKING_PREVIEW_WIDTH = 120
|
|
69
|
+
# Columns a sentence needs on the spinner row; with less, it reads "thinking...".
|
|
70
|
+
MIN_SENTENCE_ROOM = 12
|
|
71
|
+
THINKING_TOOL_PREVIEW_LIMIT = 56
|
|
72
|
+
THINKING_RENDER_MIN_INTERVAL = 0.08
|
|
73
|
+
# The spinner also turns with time: a ticker redraws it when no chunk
|
|
74
|
+
# did for this long, and after THINKING_WAIT_NOTICE_AFTER seconds with no
|
|
75
|
+
# chunk it says how long the first token has taken.
|
|
76
|
+
THINKING_TICK_INTERVAL = 0.25
|
|
77
|
+
THINKING_WAIT_NOTICE_AFTER = 2.0
|
|
78
|
+
THINKING_RENDER_INTERVAL_ENV = "SAMAGOTCHI_THINKING_RENDER_INTERVAL"
|
|
79
|
+
STATUS_LINE_ENV = "SAMAGOTCHI_STATUS_LINE"
|
|
80
|
+
STATUS_LINE_ON = "on"
|
|
81
|
+
STATUS_LINE_OFF = "off"
|
|
82
|
+
STATUS_WIDTH_MODE_ENV = "SAMAGOTCHI_STATUS_WIDTH_MODE"
|
|
83
|
+
STATUS_WIDTH_MODE_TERMINAL_CAP = "terminal_cap"
|
|
84
|
+
STATUS_WIDTH_MODE_FIXED = "fixed"
|
|
85
|
+
STATUS_FIXED_WIDTH_ENV = "SAMAGOTCHI_STATUS_FIXED_WIDTH"
|
|
86
|
+
STATUS_MAX_WIDTH_ENV = "SAMAGOTCHI_STATUS_MAX_WIDTH"
|
|
87
|
+
STATUS_MAX_WIDTH_DEFAULT = 160
|
|
88
|
+
REMINDER_PENDING_POLL_INTERVAL = 0.5
|
|
89
|
+
# What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
|
|
90
|
+
COMMAND_BUSY = "busy: wait for the turn to end"
|
|
91
|
+
# /detach is the attached terminal's; the REPL owns its session.
|
|
92
|
+
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
93
|
+
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
94
|
+
|
|
95
|
+
# Raised when another process (a `chi web` worker or another chi) owns the
|
|
96
|
+
# session this TUI was asked to run.
|
|
97
|
+
class SessionBusy < StandardError; end
|
|
98
|
+
# --resume with an id that has no saved session.
|
|
99
|
+
class SessionNotFound < StandardError; end
|
|
100
|
+
|
|
101
|
+
# ── System prompts (delegated to Engine) ─────────────────────────────────
|
|
102
|
+
def self.system_prompt_for(profile)
|
|
103
|
+
Engine.system_prompt_for(profile)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
107
|
+
spinner_tick_interval: THINKING_TICK_INTERVAL)
|
|
108
|
+
@mode = mode.to_sym
|
|
109
|
+
# nil: no ticker thread (specs that compare exact frames).
|
|
110
|
+
@spinner_tick_interval = spinner_tick_interval
|
|
111
|
+
@spinner_lock = Monitor.new
|
|
112
|
+
@turn_tally = TurnTally.new
|
|
113
|
+
@thinking_line = ThinkingLine.new(clock: -> { monotonic_time })
|
|
114
|
+
@prompt = prompt
|
|
115
|
+
@default_model_name = ModelProfile.required_model_name(nil)
|
|
116
|
+
aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
|
|
117
|
+
flag_model_name = aliased_model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(aliased_model_name)
|
|
118
|
+
@effective_model_name = flag_model_name || @default_model_name
|
|
119
|
+
@host_registry = host_registry || Samagotchi::HostRegistry.new
|
|
120
|
+
# An injected client (specs) stands in for every host's client.
|
|
121
|
+
@host_registry.client_override = client if client
|
|
122
|
+
@client = @host_registry.resolve(@effective_model_name).client
|
|
123
|
+
# Own the session before loading it, so a worker can't write a turn
|
|
124
|
+
# between the load and the lock that this TUI would later save over.
|
|
125
|
+
# An unknown id is refused before the claim, which would leave an
|
|
126
|
+
# empty session dir behind.
|
|
127
|
+
if session_id && !Session.exist?(session_id)
|
|
128
|
+
raise SessionNotFound, "Session not found: #{session_id}"
|
|
129
|
+
end
|
|
130
|
+
claim_session!(session_id) if session_id
|
|
131
|
+
@resume_session = session_id ? Session.load(session_id) : nil
|
|
132
|
+
if @resume_session
|
|
133
|
+
# --model overrides resumed session's model (runtime only, default unchanged)
|
|
134
|
+
if flag_model_name
|
|
135
|
+
@effective_model_name = flag_model_name
|
|
136
|
+
else
|
|
137
|
+
@effective_model_name = @resume_session.model_name.to_s.strip.empty? ? @default_model_name : @resume_session.model_name
|
|
138
|
+
end
|
|
139
|
+
# Re-resolve client after resume may change effective model
|
|
140
|
+
@client = @host_registry.resolve(@effective_model_name).client
|
|
141
|
+
end
|
|
142
|
+
# Only a caller's profile goes in; otherwise the Engine resolves one
|
|
143
|
+
# for the effective model and hands it to the kernel.
|
|
144
|
+
@kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
|
|
145
|
+
@no_default_input = no_default_input
|
|
146
|
+
@non_interactive = non_interactive
|
|
147
|
+
# --memory and --mute, with a resumed session's own lists first: a
|
|
148
|
+
# session started attached with mutes keeps them here.
|
|
149
|
+
@requested_memories = Array(memories)
|
|
150
|
+
@muted_memory_names = Array(muted_memories)
|
|
151
|
+
if @resume_session
|
|
152
|
+
@requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
|
|
153
|
+
@muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
|
|
154
|
+
end
|
|
155
|
+
# A recap written while idle, printed by the main thread at the open
|
|
156
|
+
# prompt (#flush_pending_recap).
|
|
157
|
+
@pending_recap = nil
|
|
158
|
+
# Cards and notices announced between turns, printed by the main
|
|
159
|
+
# thread at the open prompt (#flush_pending_cards).
|
|
160
|
+
@pending_cards = Queue.new
|
|
161
|
+
# Every terminal write goes through the surface. The REPL swaps in a
|
|
162
|
+
# live region when the terminal can show one (#assist_loop), unless it
|
|
163
|
+
# was given a surface to draw on.
|
|
164
|
+
@surface = surface || LegacySurface.new
|
|
165
|
+
@surface_given = !surface.nil?
|
|
166
|
+
@renderer = EventRenderer.new(self)
|
|
167
|
+
@render_event = ->(event) { handle_stream_event(event) }
|
|
168
|
+
@engine = Engine.new(
|
|
169
|
+
mode: :assist,
|
|
170
|
+
client: client,
|
|
171
|
+
host_registry: @host_registry,
|
|
172
|
+
profile: profile,
|
|
173
|
+
session_id: session_id,
|
|
174
|
+
no_interrupt: no_interrupt,
|
|
175
|
+
model_name: @default_model_name,
|
|
176
|
+
memories: @requested_memories,
|
|
177
|
+
muted_memories: @muted_memory_names,
|
|
178
|
+
kernel: @kernel,
|
|
179
|
+
recap: recap_config,
|
|
180
|
+
reminders: {
|
|
181
|
+
callback: lambda { |due_names|
|
|
182
|
+
# When a reminder is due, queue a synthetic turn that
|
|
183
|
+
# run_assist_loop checks before read_input. The synthetic turn
|
|
184
|
+
# uses an empty prompt so the agent can see
|
|
185
|
+
# [SYSTEM: REMINDERS DUE] and act on them.
|
|
186
|
+
@engine.note_due_reminders(due_names)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
# -p without --non-interactive drops into the REPL, which can answer
|
|
191
|
+
# an approval; --non-interactive can't, so an approval there denies.
|
|
192
|
+
@engine.interface = @non_interactive ? :non_interactive : :repl
|
|
193
|
+
@turn_flow = TurnFlow.new(engine: @engine)
|
|
194
|
+
# A recap written while a continue offer waits says the turn stopped
|
|
195
|
+
# unfinished. (Answering at the prompt is typing: activity already.)
|
|
196
|
+
@engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
|
|
197
|
+
@commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
|
|
198
|
+
registry: @engine.command_registry)
|
|
199
|
+
# Runtime --model flag or resumed session: switch the Engine (client,
|
|
200
|
+
# kernel profile) without persisting the default.
|
|
201
|
+
@engine.switch_model!(@effective_model_name) if @effective_model_name != @default_model_name
|
|
202
|
+
# Render an idle session-recap via the cursor-safe background writer; the
|
|
203
|
+
# detector itself is Engine-owned (see Engine#recap) and opt-in.
|
|
204
|
+
@recap_handle = @engine.subscribe(observer: ->(event) { handle_recap_ready(event) })
|
|
205
|
+
@question_handle = @engine.subscribe(observer: ->(event) { handle_question_event(event) })
|
|
206
|
+
@card_handle = @engine.subscribe(observer: ->(event) { handle_card_event(event) })
|
|
207
|
+
# The REPL renders events from here on: the load warnings and notices
|
|
208
|
+
# now (at the first prompt), and the plugins' slow setup in the
|
|
209
|
+
# background. A --non-interactive run leaves both to its turn.
|
|
210
|
+
unless @non_interactive
|
|
211
|
+
@engine.announce_load_events!
|
|
212
|
+
@engine.start_init_tasks!
|
|
213
|
+
end
|
|
214
|
+
# Synchronous TUI handler for in-turn ask_user_question: the turn thread IS the
|
|
215
|
+
# REPL thread (run_engine_turn runs Engine#run_turn inline), so we must render
|
|
216
|
+
# and collect input on the SAME thread without parking on a second thread.
|
|
217
|
+
@engine.set_question_sync_handler do |pending|
|
|
218
|
+
# render_question_widget records the choice via Engine#answer_question,
|
|
219
|
+
# which Engine.request_question reads back once this handler returns.
|
|
220
|
+
render_question_widget(pending)
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Single dispatch for all entrypoints (interactive REPL, --prompt,
|
|
226
|
+
# --non-interactive, --resume). Builds the working session and its seed
|
|
227
|
+
# messages once, runs a single prompt turn when -p/--prompt is given
|
|
228
|
+
# (auto-executing it, then saving), then either exits when there is no
|
|
229
|
+
# follow-up REPL (--non-interactive) or drops into the REPL carrying the
|
|
230
|
+
# post-turn conversation.
|
|
231
|
+
def run
|
|
232
|
+
unless @mode == :assist
|
|
233
|
+
raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
237
|
+
# nothing and return (no transient session, no banner).
|
|
238
|
+
return if @non_interactive && @prompt.nil?
|
|
239
|
+
|
|
240
|
+
session = @resume_session || Session.new_session(
|
|
241
|
+
mode: @mode.to_s,
|
|
242
|
+
model_name: @effective_model_name,
|
|
243
|
+
working_directory: Dir.pwd,
|
|
244
|
+
preloaded_memory_names: @requested_memories,
|
|
245
|
+
muted_memory_names: @muted_memory_names
|
|
246
|
+
)
|
|
247
|
+
claim_session!(session.id) unless @owner_lock
|
|
248
|
+
# Attach before building the prompt so it can name the session id.
|
|
249
|
+
@engine.session = session
|
|
250
|
+
messages = messages_for(session)
|
|
251
|
+
|
|
252
|
+
if @prompt && @non_interactive
|
|
253
|
+
# Headless / CI mode: run directly without TTY rendering.
|
|
254
|
+
result = @engine.run_turn(
|
|
255
|
+
session,
|
|
256
|
+
@prompt,
|
|
257
|
+
on_event: nil,
|
|
258
|
+
max_iterations: 1000,
|
|
259
|
+
cancel_controller: nil,
|
|
260
|
+
images: ImageInput.extract(@prompt)
|
|
261
|
+
)
|
|
262
|
+
@surface.commit(result.output)
|
|
263
|
+
session.save
|
|
264
|
+
return
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
assist_loop(session: session, messages: messages)
|
|
268
|
+
# After the idle layer has stopped: nothing writes the session now.
|
|
269
|
+
if @delete_on_exit
|
|
270
|
+
delete_after_exit(session)
|
|
271
|
+
elsif @discard_on_exit
|
|
272
|
+
discard_after_exit(session)
|
|
273
|
+
else
|
|
274
|
+
keep_after_exit(session)
|
|
275
|
+
end
|
|
276
|
+
ensure
|
|
277
|
+
# However it ends (the early returns too; the Engine was built in
|
|
278
|
+
# #initialize): the anytime commands finish, the plugins' services
|
|
279
|
+
# stop (a server process).
|
|
280
|
+
@engine&.shutdown
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The session stays: the recap first, then the resume line, last.
|
|
284
|
+
def keep_after_exit(session)
|
|
285
|
+
recap_after_exit
|
|
286
|
+
@surface.commit("\nContinue session: chi --resume #{session.id}")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The recap for coming back: written before the REPL ends (it has to
|
|
290
|
+
# wait, same process), when there is something new to recap. Ctrl-C
|
|
291
|
+
# gives up on it.
|
|
292
|
+
def recap_after_exit
|
|
293
|
+
@engine.write_recap_now(on_start: -> { @surface.commit("writing a recap…") })
|
|
294
|
+
rescue Interrupt
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Nothing happened in the session (SessionManager.empty_session?). The
|
|
299
|
+
# REPL saves only after a turn and keeps /model in its Engine, so a
|
|
300
|
+
# session never saved is judged from memory, and one on another model
|
|
301
|
+
# than the default (/model, --model) is kept.
|
|
302
|
+
def discard_on_exit?(session)
|
|
303
|
+
return false unless SessionManager.discard_empty?
|
|
304
|
+
return false unless @effective_model_name == @default_model_name && Array(@engine.used_memory_names).empty?
|
|
305
|
+
if File.exist?(File.join(Session.default_state_dir, "#{session.id}#{Session::FILE_EXT}"))
|
|
306
|
+
return SessionManager.empty_session?(session.id, default_model: @default_model_name)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
SessionManager.no_conversation?(session.messages) && session.last_prompt.to_s.strip.empty? &&
|
|
310
|
+
SessionManager.empty_session_dir?(Session.session_dir(session.id))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A session left empty: give it up and delete it, one quiet line.
|
|
314
|
+
def discard_after_exit(session)
|
|
315
|
+
@owner_lock&.release
|
|
316
|
+
@owner_lock = nil
|
|
317
|
+
SessionManager.delete_session(session.id)
|
|
318
|
+
@surface.commit("The session was empty, so it is discarded.")
|
|
319
|
+
rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
|
|
320
|
+
@surface.commit("Continue session: chi --resume #{session.id} (the empty session was not discarded: #{e.message})")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# /exit --delete: give up the session, then delete it for good.
|
|
324
|
+
def delete_after_exit(session)
|
|
325
|
+
@owner_lock&.release
|
|
326
|
+
@owner_lock = nil
|
|
327
|
+
SessionManager.delete_session(session.id)
|
|
328
|
+
@surface.commit("Deleted session #{session.id}.")
|
|
329
|
+
rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
|
|
330
|
+
@surface.commit("Session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
334
|
+
# its own Engine, so no worker may run the session meanwhile.
|
|
335
|
+
# @raise [SessionBusy] when a worker or another TUI owns it
|
|
336
|
+
def claim_session!(session_id)
|
|
337
|
+
session_dir = Session.session_dir(session_id)
|
|
338
|
+
@owner_lock = OwnerLock.acquire(session_dir, kind: "tui", wait: 1.0)
|
|
339
|
+
return @owner_lock if @owner_lock
|
|
340
|
+
|
|
341
|
+
owner = OwnerLock.owner(session_dir) || {}
|
|
342
|
+
where = owner["kind"] == "tui" ? "another chi" : "a `chi web` worker"
|
|
343
|
+
raise SessionBusy, "Session #{session_id} is open in #{where} (pid #{owner["pid"] || "unknown"}). " \
|
|
344
|
+
"Close it there first."
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Build the seed messages for the working session.
|
|
348
|
+
#
|
|
349
|
+
# Resumed sessions keep their prior conversation (only the system-prompt
|
|
350
|
+
# slot is replaced); fresh sessions start with just the system prompt.
|
|
351
|
+
# Prints a one-line banner so the user sees which session they're in.
|
|
352
|
+
def messages_for(session)
|
|
353
|
+
system_message = { role: "system", content: seed_system_prompt }
|
|
354
|
+
if @resume_session
|
|
355
|
+
messages = ContextNote.with_system_head(session.messages.dup, system_message)
|
|
356
|
+
@surface.commit("Resumed session: #{session.id}")
|
|
357
|
+
saved = @engine.saved_recap
|
|
358
|
+
@surface.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
|
|
359
|
+
else
|
|
360
|
+
messages = [system_message]
|
|
361
|
+
@surface.commit("Session: #{session.id}")
|
|
362
|
+
end
|
|
363
|
+
messages
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Public entrypoint for background session workers.
|
|
367
|
+
# Keeps worker call sites out of TerminalUI private API details.
|
|
368
|
+
def process_background_prompt(session:, prompt:)
|
|
369
|
+
@engine.process_background_prompt(session: session, prompt: prompt)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
private
|
|
373
|
+
|
|
374
|
+
# Generate profile-aware tool calling hint.
|
|
375
|
+
# Kept as a one-line delegator to Engine (single source of truth).
|
|
376
|
+
def tool_call_hint
|
|
377
|
+
return @engine.tool_call_hint
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Assist system prompt — delegates wholesale to Engine so the two can
|
|
381
|
+
# never drift apart. This single delegator fixes the 4-line regression.
|
|
382
|
+
def assist_system_prompt
|
|
383
|
+
return @engine.assist_system_prompt
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
387
|
+
# threaded in here (so --prompt / --resume share one code path). The
|
|
388
|
+
# working session is persisted at the end of every turn.
|
|
389
|
+
def assist_loop(session:, messages:)
|
|
390
|
+
load_persistent_history
|
|
391
|
+
|
|
392
|
+
# queue_default_input self-guards on @resume_session, so calling it
|
|
393
|
+
# unconditionally preserves the original fresh-session prefill behavior.
|
|
394
|
+
# Skip the default input prefill when a user-provided --prompt was used —
|
|
395
|
+
# the explicit prompt means the user is in command and shouldn't see the
|
|
396
|
+
# default input ("Please " etc.) on the next REPL prompt.
|
|
397
|
+
queue_default_input if @prompt.nil?
|
|
398
|
+
|
|
399
|
+
# The idle layer (reminders + optional recap) is Engine-owned; start the
|
|
400
|
+
# shared scheduler for this REPL and stop it on exit. with_activity_hook
|
|
401
|
+
# resets the shared inactivity clock when a prompt opens and on each key.
|
|
402
|
+
with_live_region do
|
|
403
|
+
with_interrupt_arbiter do
|
|
404
|
+
@engine.start_idle
|
|
405
|
+
with_activity_hook do
|
|
406
|
+
run_assist_loop(session: session, messages: messages)
|
|
407
|
+
ensure
|
|
408
|
+
@engine.stop_idle
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Ctrl-C while a Reline read owns stdin: Reline's INT trap catches it, so
|
|
415
|
+
# the REPL hears of it through RelineSeam. A running turn is cancelled and
|
|
416
|
+
# the prompt stays as typed; with no turn running it is Reline's Ctrl-C.
|
|
417
|
+
# (With no read open, Ctrl-C stays an Interrupt on the turn's thread.)
|
|
418
|
+
def with_interrupt_arbiter
|
|
419
|
+
return yield unless RelineSeam.supported?
|
|
420
|
+
|
|
421
|
+
RelineSeam.install
|
|
422
|
+
previous = RelineSeam.interrupt_handler
|
|
423
|
+
RelineSeam.interrupt_handler = method(:cancel_turn_from_prompt)
|
|
424
|
+
begin
|
|
425
|
+
yield
|
|
426
|
+
ensure
|
|
427
|
+
RelineSeam.interrupt_handler = previous
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# @return [Boolean] a running turn was cancelled
|
|
432
|
+
def cancel_turn_from_prompt
|
|
433
|
+
controller = @active_cancel_controller
|
|
434
|
+
return false unless controller
|
|
435
|
+
|
|
436
|
+
controller.cancel!(:ctrl_c)
|
|
437
|
+
true
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Draw the REPL on a live region (a Screen, with Reline's prompt in it)
|
|
441
|
+
# when the terminal can show one; plain output otherwise, or on the
|
|
442
|
+
# surface the UI was given.
|
|
443
|
+
def with_live_region
|
|
444
|
+
screen = LiveRegion.open unless @surface_given
|
|
445
|
+
return yield unless screen
|
|
446
|
+
|
|
447
|
+
plain = @surface
|
|
448
|
+
@surface = screen
|
|
449
|
+
begin
|
|
450
|
+
yield
|
|
451
|
+
ensure
|
|
452
|
+
@surface = plain
|
|
453
|
+
LiveRegion.close(screen)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
458
|
+
# threaded in here (so --prompt / --resume share one code path). The
|
|
459
|
+
# session's conversation is the single working copy: turns go through
|
|
460
|
+
# Engine#run_turn and out-of-turn edits through Engine's messages API.
|
|
461
|
+
# The checkpoint and continue state live in @turn_flow (shared with
|
|
462
|
+
# session workers). The session is persisted at the end of every turn.
|
|
463
|
+
def run_assist_loop(session:, messages:)
|
|
464
|
+
session.messages = messages
|
|
465
|
+
@engine.session = session
|
|
466
|
+
# Steering: lines submitted at the open prompt during a turn
|
|
467
|
+
# (#steer_line), merged by the kernel (#drain_steering).
|
|
468
|
+
@pending_input_queue = PendingInputQueue.new
|
|
469
|
+
open_repl_input
|
|
470
|
+
|
|
471
|
+
loop do
|
|
472
|
+
if @exit_after_turn
|
|
473
|
+
# Ctrl-D or exit came during a turn: the lines sent before it still
|
|
474
|
+
# run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
|
|
475
|
+
input = queued_line
|
|
476
|
+
break if input.nil?
|
|
477
|
+
else
|
|
478
|
+
# Drain any pending ask_user_question first — it has priority over reminders and
|
|
479
|
+
# must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
|
|
480
|
+
drain_pending_question?
|
|
481
|
+
|
|
482
|
+
# A due reminder runs its turn now, with the prompt open (on a
|
|
483
|
+
# terminal): the turn's output commits above it and whatever is typed
|
|
484
|
+
# there stays.
|
|
485
|
+
unless @engine.due_reminder_names.empty?
|
|
486
|
+
run_reminder_turn(session)
|
|
487
|
+
@turn_flow.after_reminder_turn
|
|
488
|
+
next
|
|
489
|
+
end
|
|
490
|
+
input = @prompt
|
|
491
|
+
@prompt = nil if input
|
|
492
|
+
if input.nil?
|
|
493
|
+
input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
|
|
494
|
+
# A reminder fell due: run it at the top, with the prompt still open.
|
|
495
|
+
next if input == :due
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
break if input.nil?
|
|
499
|
+
if exit_command?(input)
|
|
500
|
+
@delete_on_exit ||= delete_on_exit?(input)
|
|
501
|
+
break
|
|
502
|
+
end
|
|
503
|
+
# Not an answer to a continue offer either.
|
|
504
|
+
next detach_note if detach_command?(input)
|
|
505
|
+
|
|
506
|
+
# /stats and /recap run; the offer stays open.
|
|
507
|
+
if @turn_flow.awaiting_continue? && !stats_command?(input) && !recap_command?(input)
|
|
508
|
+
answer_continue_offer(session, input)
|
|
509
|
+
else
|
|
510
|
+
# The ? read left no echo: show what ran.
|
|
511
|
+
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
|
|
512
|
+
run_input_line(session, input)
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
close_repl_input
|
|
517
|
+
@discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# An answer at the ? prompt of a continue offer. A valid one closes the
|
|
521
|
+
# offer's choices and leaves one line; an invalid one gets its error.
|
|
522
|
+
def answer_continue_offer(session, input)
|
|
523
|
+
answer = @commands.continue_answer(input)
|
|
524
|
+
if answer.decision == :invalid
|
|
525
|
+
# The read left no echo: the line shows above its error.
|
|
526
|
+
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
|
|
527
|
+
else
|
|
528
|
+
sync_continue_slot(false)
|
|
529
|
+
@surface.commit(QuestionSlot.continue_summary(input.to_s.strip.empty? ? "yes" : input.to_s.strip, paint: method(:paint)))
|
|
530
|
+
end
|
|
531
|
+
return show_command_result(answer) unless answer.resume
|
|
532
|
+
|
|
533
|
+
@turn_flow.before_continue_turn
|
|
534
|
+
begin
|
|
535
|
+
result = run_engine_turn(session, nil, continue: true)
|
|
536
|
+
rescue LLM::ProviderError => e
|
|
537
|
+
@surface.commit("\nmodel> #{e.summary}; continue prompt preserved")
|
|
538
|
+
return
|
|
539
|
+
end
|
|
540
|
+
finish_turn(session, result, continue: true)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# A line at the main prompt: a command, or a prompt for a turn.
|
|
544
|
+
def run_input_line(session, input)
|
|
545
|
+
return if input.empty?
|
|
546
|
+
|
|
547
|
+
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
548
|
+
# an anytime command's cards print as it shows them (btw's
|
|
549
|
+
# "thinking…" before the answer).
|
|
550
|
+
command = if command_registry.lookup(input)&.anytime
|
|
551
|
+
@engine.running_anytime { @commands.run(input) }
|
|
552
|
+
else
|
|
553
|
+
@commands.run(input)
|
|
554
|
+
end
|
|
555
|
+
if command
|
|
556
|
+
show_command_result(command)
|
|
557
|
+
persist_recent_history(input) if command.shell
|
|
558
|
+
return
|
|
559
|
+
end
|
|
560
|
+
return @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}") if stats_command?(input)
|
|
561
|
+
return @surface.commit("\nmodel> #{handle_recap_command}") if recap_command?(input)
|
|
562
|
+
|
|
563
|
+
@turn_flow.before_prompt_turn
|
|
564
|
+
persist_recent_history(input)
|
|
565
|
+
begin
|
|
566
|
+
# Engine#run_turn injects due reminders as a tail message, appends
|
|
567
|
+
# the prompt, and renders through @renderer via on_event.
|
|
568
|
+
result = run_engine_turn(session, normalize_model_input(input), images: ImageInput.extract(input))
|
|
569
|
+
rescue LLM::ProviderError, ImageStore::Error => e
|
|
570
|
+
# Engine closed the turn (:turn_failed); show its duration.
|
|
571
|
+
# Retries were already tallied via generation_retrying events.
|
|
572
|
+
# An @path image that can't be used fails the turn the same way.
|
|
573
|
+
emit_interactive_turn_duration(canceled: false)
|
|
574
|
+
summary = e.respond_to?(:summary) ? e.summary : e.message
|
|
575
|
+
# The model reads why on its next turn. An image that couldn't be
|
|
576
|
+
# used, or that the host refused, never reached it: no note for that
|
|
577
|
+
# (a first turn refused that way leaves the session empty, as before).
|
|
578
|
+
note = TurnNote.failed(summary, restored: true) if e.is_a?(LLM::ProviderError) && !e.is_a?(LLM::VisionUnsupported)
|
|
579
|
+
@turn_flow.prompt_turn_failed(note: note)
|
|
580
|
+
# The Engine saved the failed turn; the file follows the rollback.
|
|
581
|
+
save_session(session) if note
|
|
582
|
+
@surface.commit("\nmodel> #{summary}; #{restore_prompt_for_retry(input)}")
|
|
583
|
+
return
|
|
584
|
+
end
|
|
585
|
+
finish_turn(session, result, continue: false)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# After a prompt or continue turn: TurnFlow keeps the checkpoint and the
|
|
589
|
+
# continue offer; the REPL saves and tells the user.
|
|
590
|
+
def finish_turn(session, result, continue:)
|
|
591
|
+
if result.respond_to?(:canceled?) && result.canceled?
|
|
592
|
+
outcome = @turn_flow.after_turn(result, continue: continue)
|
|
593
|
+
# A cancelled continue is back where it started, the offer still open.
|
|
594
|
+
return if outcome == :continue_cancelled
|
|
595
|
+
|
|
596
|
+
# Ctrl-C on a fresh turn. The kernel salvaged completed tool calls
|
|
597
|
+
# and the partial assistant reply (marked [interrupted]) into
|
|
598
|
+
# result.conversation, so progress is preserved by default — the
|
|
599
|
+
# user's next message continues from it. !rollback restores the
|
|
600
|
+
# pre-turn checkpoint for an explicit full discard.
|
|
601
|
+
emit_interactive_turn_duration(canceled: true)
|
|
602
|
+
save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
|
|
603
|
+
@surface.commit("\nmodel> turn cancelled; partial progress kept in context; use !rollback immediately after cancellation to restore the pre-turn checkpoint")
|
|
604
|
+
return
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# The REPL keeps the kernel's conversation as-is (no [No response]
|
|
608
|
+
# placeholder), so /continue resumes from the tool results.
|
|
609
|
+
session.messages = result.conversation
|
|
610
|
+
@turn_flow.after_turn(result, continue: continue)
|
|
611
|
+
save_session(session)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def save_session(session)
|
|
615
|
+
session.model_name = @effective_model_name
|
|
616
|
+
session.save
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# A !cmd shows its own output; everything else is the model> line.
|
|
620
|
+
def show_command_result(result)
|
|
621
|
+
if result.shell
|
|
622
|
+
@surface.commit(result.output)
|
|
623
|
+
@surface.commit("")
|
|
624
|
+
elsif !result.output.nil?
|
|
625
|
+
@surface.commit("\nmodel> #{result.output}")
|
|
626
|
+
end
|
|
627
|
+
sync_model_mirrors if result.changed.include?(:model)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# The status line and the next session save read these.
|
|
631
|
+
def sync_model_mirrors
|
|
632
|
+
@effective_model_name = @engine.effective_model_name
|
|
633
|
+
@default_model_name = @commands.default_model
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def status_server_segment
|
|
637
|
+
# Show per-host info when multi-host is configured
|
|
638
|
+
if @host_registry && @host_registry.entries.size > 1
|
|
639
|
+
active = @host_registry.resolve(@effective_model_name).entry rescue nil
|
|
640
|
+
if active
|
|
641
|
+
host = active.host
|
|
642
|
+
port = active.port
|
|
643
|
+
total = @host_registry.entries.size
|
|
644
|
+
return "" if ["localhost", "127.0.0.1"].include?(host) && total == 1
|
|
645
|
+
# When multiple hosts, always show active + count
|
|
646
|
+
return "server=#{host}:#{port} (#{total} hosts)"
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
host = Samagotchi::Config.get("server.host")
|
|
650
|
+
return "" if ["localhost", "127.0.0.1"].include?(host)
|
|
651
|
+
|
|
652
|
+
port = Samagotchi::Config.get("server.port")
|
|
653
|
+
"server=#{host}:#{port}"
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Engine's built-once system prompt (the one run_turn sends), plus the
|
|
657
|
+
# --memory activations it recorded for the sticky status line.
|
|
658
|
+
def seed_system_prompt
|
|
659
|
+
prompt = @engine.system_prompt
|
|
660
|
+
sync_engine_activated_memories
|
|
661
|
+
prompt
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Appends the current memory index to the base system prompt so the agent
|
|
665
|
+
# is always aware of stored memories without needing to call a tool first.
|
|
666
|
+
# Delegates wholesale to Engine (single source of truth).
|
|
667
|
+
def system_prompt_with_index(base)
|
|
668
|
+
result = @engine.system_prompt_with_index(base)
|
|
669
|
+
# Mirror any --memory activations the Engine performed so the sticky
|
|
670
|
+
# status line can surface them. The prompt body injection moved into
|
|
671
|
+
# Engine; echoing the activated names here is purely a UI concern.
|
|
672
|
+
sync_engine_activated_memories
|
|
673
|
+
result
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Engine owns the system prompt (including --memory activation), but the
|
|
677
|
+
# sticky status line is a UI concern. Mirror the activated names so they
|
|
678
|
+
# appear in the status line.
|
|
679
|
+
def sync_engine_activated_memories
|
|
680
|
+
@engine.activated_memory_names.each do |name|
|
|
681
|
+
add_unique_memory_name(:@session_memory_names, name)
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Render a result from a turn that bypassed Engine#run_turn (continue,
|
|
686
|
+
# reminder turns) exactly as a :turn_completed would be rendered.
|
|
687
|
+
def emit_result(result)
|
|
688
|
+
@renderer.render_turn_summary(@engine.turn_summary(result))
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# ── Turn view: the drawing surface EventRenderer calls ──────────────────
|
|
692
|
+
public
|
|
693
|
+
|
|
694
|
+
def emit_active_memories_line
|
|
695
|
+
lines = sticky_status_lines(width: status_effective_width)
|
|
696
|
+
return if lines.empty?
|
|
697
|
+
|
|
698
|
+
lines.each { |line| @surface.commit(line) }
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def print_line(text)
|
|
702
|
+
@surface.commit(text)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def reset_turn_feedback
|
|
706
|
+
@spinner_lock.synchronize { @turn_tally.reset }
|
|
707
|
+
clear_retry_spinner_status
|
|
708
|
+
reset_thinking_memory_notification
|
|
709
|
+
reset_thinking_memory_names
|
|
710
|
+
reset_thinking_tool_notification
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# A turn waits for plugins' slow setup (chi.init) before its first
|
|
714
|
+
# request: the spinner says what for, until the model starts.
|
|
715
|
+
def init_wait_feedback(event)
|
|
716
|
+
labels = Array(event[:tasks]).map { |task| "#{task[:bundle]}: #{task[:label]}" }
|
|
717
|
+
@spinner_lock.synchronize do
|
|
718
|
+
@init_wait_status = "chi> waiting for #{labels.join(" · ")}..."
|
|
719
|
+
start_thinking_spinner
|
|
720
|
+
refresh_thinking_spinner_status
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def generation_feedback_started(event = {})
|
|
725
|
+
@init_wait_status = nil
|
|
726
|
+
@context_window_tokens = event[:context_window_tokens] if event[:context_window_tokens]
|
|
727
|
+
clear_retry_spinner_status
|
|
728
|
+
@latest_server_context_status = nil
|
|
729
|
+
@spinner_lock.synchronize do
|
|
730
|
+
@thinking_line.reset
|
|
731
|
+
start_thinking_spinner
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def generation_feedback_retrying(event)
|
|
736
|
+
@spinner_lock.synchronize do
|
|
737
|
+
# The retry streams from the start: wait for its first token again.
|
|
738
|
+
@thinking_waiting_since = monotonic_time if @thinking_spinner_active
|
|
739
|
+
set_retry_spinner_status(event)
|
|
740
|
+
refresh_thinking_spinner_status
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def generation_feedback_chunk(event)
|
|
745
|
+
@spinner_lock.synchronize do
|
|
746
|
+
@thinking_waiting_since = nil
|
|
747
|
+
clear_retry_spinner_status if retry_spinner_status_active?
|
|
748
|
+
capture_server_context_status_from_payload(event[:payload])
|
|
749
|
+
# A new sentence shows at once (the line changes at most once a dwell).
|
|
750
|
+
next refresh_thinking_spinner_status if @thinking_line.chunk(event)
|
|
751
|
+
|
|
752
|
+
tick_thinking_spinner
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def tool_call_feedback_started(event)
|
|
757
|
+
@spinner_lock.synchronize do
|
|
758
|
+
@thinking_waiting_since = nil
|
|
759
|
+
clear_retry_spinner_status
|
|
760
|
+
@turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
|
|
761
|
+
memory_loaded = capture_memory_tool_call(event)
|
|
762
|
+
capture_thinking_tool_call(event) if memory_loaded
|
|
763
|
+
refresh_thinking_spinner_status
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def tool_call_feedback_completed(event)
|
|
768
|
+
@spinner_lock.synchronize do
|
|
769
|
+
@turn_tally.completed(key: tally_key(event), tool: event[:tool],
|
|
770
|
+
status: event.dig(:activity, :status), params: event.dig(:activity, :params))
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def clear_generation_retry
|
|
775
|
+
@spinner_lock.synchronize { clear_retry_spinner_status }
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def generation_feedback_finished
|
|
779
|
+
@spinner_lock.synchronize do
|
|
780
|
+
clear_retry_spinner_status
|
|
781
|
+
@thinking_line.reset
|
|
782
|
+
finish_thinking_spinner
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# The kernel reports the last emitted context status on the result; keep
|
|
787
|
+
# the previous one when a turn reports none.
|
|
788
|
+
def capture_context_status(status)
|
|
789
|
+
return unless status
|
|
790
|
+
|
|
791
|
+
@latest_context_status = {
|
|
792
|
+
est_pct: status[:est_pct],
|
|
793
|
+
bucket: status[:bucket]
|
|
794
|
+
}
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
private
|
|
798
|
+
|
|
799
|
+
# The last turn's line: completed, canceled or failed, as the Engine
|
|
800
|
+
# closed it (a provider error closes it with :turn_failed).
|
|
801
|
+
def emit_interactive_turn_duration(canceled:)
|
|
802
|
+
record = Array(@engine.metrics.snapshot[:turn_records]).last
|
|
803
|
+
return unless record && record[:duration_ms]
|
|
804
|
+
|
|
805
|
+
state = record[:status] == "failed" ? "failed" : (canceled ? "canceled" : "completed")
|
|
806
|
+
@surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def split_memory_scope(raw)
|
|
810
|
+
value = raw.to_s.strip
|
|
811
|
+
if value.include?("/")
|
|
812
|
+
scope, name = value.split("/", 2)
|
|
813
|
+
return [scope, name] if Tools::VALID_SCOPES.include?(scope)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
[nil, value]
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def read_input(awaiting_continue:)
|
|
820
|
+
emit_idle_status_line
|
|
821
|
+
|
|
822
|
+
if awaiting_continue
|
|
823
|
+
sync_continue_slot(true)
|
|
824
|
+
input = Reline.readline(paint(QUESTION_PROMPT, 33), true)
|
|
825
|
+
return nil if input.nil?
|
|
826
|
+
|
|
827
|
+
return input.strip
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
read_prompt_line(paint("> ", 92))
|
|
831
|
+
rescue Interrupt
|
|
832
|
+
nil
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# The synthetic turn for due reminders (Engine#run_turn injects them as
|
|
836
|
+
# a tail message). The hints row says which, while it runs.
|
|
837
|
+
def run_reminder_turn(session)
|
|
838
|
+
names = @engine.due_reminder_names
|
|
839
|
+
@engine.clear_due_reminder_names!
|
|
840
|
+
# A normal turn may already have injected them (a stale latch): no
|
|
841
|
+
# empty synthetic turn then (it duplicated output).
|
|
842
|
+
return unless @engine.reminders_due?
|
|
843
|
+
|
|
844
|
+
hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
|
|
845
|
+
@surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
|
|
846
|
+
result = nil
|
|
847
|
+
begin
|
|
848
|
+
result = run_engine_turn(session, nil, continue: true)
|
|
849
|
+
@prompt = nil
|
|
850
|
+
rescue LLM::ProviderError => e
|
|
851
|
+
# like other failed turns: back to the prompt
|
|
852
|
+
@surface.commit("\nmodel> #{e.summary}")
|
|
853
|
+
ensure
|
|
854
|
+
@surface.clear_slot(:hints)
|
|
855
|
+
end
|
|
856
|
+
canceled = result.respond_to?(:canceled?) && result.canceled?
|
|
857
|
+
emit_interactive_turn_duration(canceled: canceled)
|
|
858
|
+
if result && !canceled
|
|
859
|
+
session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
|
|
860
|
+
session.model_name = @effective_model_name
|
|
861
|
+
session.save
|
|
862
|
+
end
|
|
863
|
+
# The synthetic turn is activity: the next reminder waits a full
|
|
864
|
+
# interval (without it they fired back to back).
|
|
865
|
+
@engine.record_activity
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# The next line from the prompt, or :due when a reminder falls due first.
|
|
869
|
+
# On a terminal the prompt stays open the whole session (#open_repl_input):
|
|
870
|
+
# a reminder turn runs with it (and anything typed in it) still there, and
|
|
871
|
+
# a line submitted meanwhile comes next. Ctrl-C during a turn reaches
|
|
872
|
+
# #cancel_turn_from_prompt.
|
|
873
|
+
def poll_input_with_reminder_check(awaiting_continue:)
|
|
874
|
+
return :due unless @engine.due_reminder_names.empty?
|
|
875
|
+
# Specs and pipes: a plain blocking read.
|
|
876
|
+
unless @repl_input
|
|
877
|
+
flush_pending_cards
|
|
878
|
+
return read_input(awaiting_continue: awaiting_continue)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
if @idle_status_due
|
|
882
|
+
@idle_status_due = false
|
|
883
|
+
emit_idle_status_line
|
|
884
|
+
end
|
|
885
|
+
sync_continue_slot(awaiting_continue)
|
|
886
|
+
@repl_input.sync_prompt
|
|
887
|
+
loop do
|
|
888
|
+
flush_pending_recap
|
|
889
|
+
flush_pending_cards
|
|
890
|
+
kind, line = @repl_input.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
|
|
891
|
+
if kind
|
|
892
|
+
# What the line does may change the status (/model, a turn).
|
|
893
|
+
@idle_status_due = true
|
|
894
|
+
# Ctrl-C at an idle prompt ends the REPL, as Ctrl-D does.
|
|
895
|
+
return kind == :line ? line : nil
|
|
896
|
+
end
|
|
897
|
+
return :due unless @engine.due_reminder_names.empty?
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# On a terminal: one read for the whole session, on a LineReader.
|
|
902
|
+
def open_repl_input
|
|
903
|
+
return unless STDIN.tty? && $stdin.tty?
|
|
904
|
+
|
|
905
|
+
@idle_status_due = false
|
|
906
|
+
emit_idle_status_line
|
|
907
|
+
@repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def close_repl_input
|
|
911
|
+
@repl_input&.stop
|
|
912
|
+
@repl_input = nil
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# The main prompt, or ? when a continue offer waits and no turn runs.
|
|
916
|
+
def repl_prompt_text
|
|
917
|
+
return paint("> ", 92) if @active_cancel_controller || !@turn_flow.awaiting_continue?
|
|
918
|
+
|
|
919
|
+
paint(QUESTION_PROMPT, 33)
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# The notes slot shows a continue offer's choices while it waits.
|
|
923
|
+
def sync_continue_slot(shown)
|
|
924
|
+
if shown
|
|
925
|
+
return if @continue_slot
|
|
926
|
+
|
|
927
|
+
@continue_slot = true
|
|
928
|
+
@surface.set_slot(:notes, QuestionSlot.continue_offer(@turn_flow.offer&.dig(:context), paint: method(:paint)))
|
|
929
|
+
elsif @continue_slot
|
|
930
|
+
@continue_slot = false
|
|
931
|
+
@surface.clear_slot(:notes)
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# One read on the reader thread (ReplInput): the multiline read with Tab
|
|
936
|
+
# completion at the main prompt, a plain line for the continue offer and
|
|
937
|
+
# a question's ? prompt. +prefill+ is typed in first.
|
|
938
|
+
def read_repl_line(prompt, prefill)
|
|
939
|
+
queue_input_prefill(prefill) if prefill
|
|
940
|
+
return read_prompt_line(prompt) if prompt == paint("> ", 92)
|
|
941
|
+
|
|
942
|
+
read = -> { with_next_input_prefill { Reline.readline(prompt, true) }&.strip }
|
|
943
|
+
# A question's answer leaves only its summary line.
|
|
944
|
+
prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# A failed prompt goes back into the input for a retry.
|
|
948
|
+
# @return [String] what the failed-turn line says about it
|
|
949
|
+
def restore_prompt_for_retry(input)
|
|
950
|
+
unless @repl_input
|
|
951
|
+
queue_input_prefill(input)
|
|
952
|
+
return "prompt restored for retry"
|
|
953
|
+
end
|
|
954
|
+
@repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def shell_bang_command?(input)
|
|
958
|
+
input.to_s.match?(/\A!\s*\S/)
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def stats_command?(input)
|
|
962
|
+
input.to_s.strip == STATS_COMMAND
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def recap_command?(input)
|
|
966
|
+
input.to_s.strip == RECAP_COMMAND
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# SessionCommands runs /model; the REPL mirrors the result.
|
|
970
|
+
def handle_model_command(input)
|
|
971
|
+
result = @commands.run(input)
|
|
972
|
+
sync_model_mirrors
|
|
973
|
+
result.output
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def handle_models_command
|
|
977
|
+
@commands.run(SessionCommands::MODELS_COMMAND).output
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# /recap: the saved recap, and a new one asked for at once when the
|
|
981
|
+
# chat moved on (it prints when it arrives, #flush_pending_recap).
|
|
982
|
+
def handle_recap_command
|
|
983
|
+
recap = @engine.recap
|
|
984
|
+
return recap_command_text(enabled: false) unless recap
|
|
985
|
+
|
|
986
|
+
saved = @engine.saved_recap
|
|
987
|
+
request = @engine.turn_running? ? :busy : @engine.request_recap
|
|
988
|
+
recap_command_text(enabled: true, saved: saved, request: request, min_user_turns: recap.min_user_turns)
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# A resumed session doesn't get the default input either.
|
|
992
|
+
def default_input_wanted? = !@resume_session && !@no_default_input
|
|
993
|
+
|
|
994
|
+
# exit or /exit, with --delete to delete the session on the way out.
|
|
995
|
+
def exit_command?(input)
|
|
996
|
+
words = input.to_s.strip.downcase.split
|
|
997
|
+
%w[exit /exit].include?(words.first) && (words.size == 1 || words == [words.first, EXIT_DELETE_FLAG])
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
def delete_on_exit?(input) = input.to_s.strip.downcase.split.last == EXIT_DELETE_FLAG
|
|
1001
|
+
|
|
1002
|
+
def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
|
|
1003
|
+
|
|
1004
|
+
def clone_messages(messages)
|
|
1005
|
+
Array(messages).map(&:dup)
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def skip_agent_description?
|
|
1009
|
+
value = ENV[SKIP_AGENT_DESCRIPTION_ENV]
|
|
1010
|
+
value == "1" || value&.casecmp?("true")
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
def rg_available?
|
|
1014
|
+
system("command -v rg", out: File::NULL, err: File::NULL)
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
1018
|
+
# Engine#run_turn, rendering via @renderer.
|
|
1019
|
+
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
1020
|
+
def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
|
|
1021
|
+
cancellation_controller = CancellationController.new
|
|
1022
|
+
@active_cancel_controller = cancellation_controller
|
|
1023
|
+
@renderer.begin_turn
|
|
1024
|
+
# Build the (memoized) prompt now so --memory activations show in this
|
|
1025
|
+
# turn's status lines, including after /model rebuilt it.
|
|
1026
|
+
seed_system_prompt
|
|
1027
|
+
result = with_steering do
|
|
1028
|
+
@engine.run_turn(
|
|
1029
|
+
session,
|
|
1030
|
+
prompt,
|
|
1031
|
+
on_event: @render_event,
|
|
1032
|
+
max_iterations: max_iterations,
|
|
1033
|
+
cancel_controller: cancellation_controller,
|
|
1034
|
+
pending_input: method(:drain_steering),
|
|
1035
|
+
continue: continue,
|
|
1036
|
+
images: images
|
|
1037
|
+
)
|
|
1038
|
+
end
|
|
1039
|
+
emit_cancellation_notice(result)
|
|
1040
|
+
result
|
|
1041
|
+
rescue Interrupt
|
|
1042
|
+
# Engine kept the prompt in the session and emitted :turn_canceled.
|
|
1043
|
+
cancellation_controller&.cancel!(:ctrl_c)
|
|
1044
|
+
result = cancelled_result_from(session.messages, reason: :ctrl_c)
|
|
1045
|
+
emit_cancellation_notice(result)
|
|
1046
|
+
result
|
|
1047
|
+
ensure
|
|
1048
|
+
@active_cancel_controller = nil
|
|
1049
|
+
finish_thinking_spinner
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
1053
|
+
# merges at the next iteration boundary (Kernel), and one that comes after
|
|
1054
|
+
# the last runs as the next turn. Reminder turns too.
|
|
1055
|
+
def with_steering(&block)
|
|
1056
|
+
return yield unless @repl_input
|
|
1057
|
+
|
|
1058
|
+
@repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain }, &block)
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
# On the reader thread, from ReplInput: takes a line for the running turn.
|
|
1062
|
+
# A line sent after Ctrl-C waits for the next turn (the kernel would not
|
|
1063
|
+
# merge it into the cancelled one). Ctrl-D or exit ends the REPL after
|
|
1064
|
+
# the turn. /stats and /recap run now, an anytime command (/help, a
|
|
1065
|
+
# plugin's /btw) starts now on its own thread; other commands wait, back
|
|
1066
|
+
# in the prompt.
|
|
1067
|
+
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1068
|
+
def steer_line(line)
|
|
1069
|
+
return exit_after_turn(delete: delete_on_exit?(line)) if line.nil? || exit_command?(line)
|
|
1070
|
+
return detach_note if detach_command?(line)
|
|
1071
|
+
return false if @active_cancel_controller&.cancelled?
|
|
1072
|
+
return start_anytime_command(line) if command_registry.lookup(line)&.anytime
|
|
1073
|
+
return command_during_turn(line) if command_line?(line)
|
|
1074
|
+
return true if line.strip.empty?
|
|
1075
|
+
# Steering merges text only: a line with images runs as the next turn.
|
|
1076
|
+
return image_line_waits if ImageInput.extract(line).any?
|
|
1077
|
+
|
|
1078
|
+
@pending_input_queue.push(line.strip)
|
|
1079
|
+
true
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
def image_line_waits
|
|
1083
|
+
@surface.commit(IMAGE_LINE_WAITS)
|
|
1084
|
+
false
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def detach_note
|
|
1088
|
+
@surface.commit(REPL_DETACH_NOTE)
|
|
1089
|
+
true
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def exit_after_turn(delete: false)
|
|
1093
|
+
@exit_after_turn = true
|
|
1094
|
+
@delete_on_exit = true if delete
|
|
1095
|
+
@surface.commit(delete ? "(exits after this turn and deletes the session; Ctrl-C cancels the turn)" : "(exits after this turn; Ctrl-C cancels it)")
|
|
1096
|
+
true
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
# A line already queued (none waits for input), or nil.
|
|
1100
|
+
def queued_line
|
|
1101
|
+
kind, line = @repl_input&.pop(timeout: 0)
|
|
1102
|
+
kind == :line ? line : nil
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
# @return [true, :back]
|
|
1106
|
+
def command_during_turn(line)
|
|
1107
|
+
if stats_command?(line)
|
|
1108
|
+
@surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
|
|
1109
|
+
elsif recap_command?(line)
|
|
1110
|
+
@surface.commit("\nmodel> #{handle_recap_command}")
|
|
1111
|
+
else
|
|
1112
|
+
@surface.commit(COMMAND_BUSY)
|
|
1113
|
+
return :back
|
|
1114
|
+
end
|
|
1115
|
+
true
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
# D8: an anytime command runs on its own thread while the turn goes on
|
|
1119
|
+
# (it reads copies, and shows things through its ctx). What it prints
|
|
1120
|
+
# while the turn runs goes above the live region now, its cards as it
|
|
1121
|
+
# shows them (#handle_card_event); once the turn has ended it waits for
|
|
1122
|
+
# the prompt's flush.
|
|
1123
|
+
# @return [true]
|
|
1124
|
+
def start_anytime_command(line)
|
|
1125
|
+
@engine.spawn_anytime do
|
|
1126
|
+
output = @engine.running_anytime { @commands.run(line) }&.output
|
|
1127
|
+
items = output.nil? ? [] : [{ type: :command_output, text: "\nmodel> #{output}" }]
|
|
1128
|
+
items.each { |item| @engine.turn_running? ? show_pending_item(item) : @pending_cards << item }
|
|
1129
|
+
rescue StandardError => e
|
|
1130
|
+
@pending_cards << { type: :command_output, text: "\nmodel> #{line.split.first}: #{e.message}" }
|
|
1131
|
+
end
|
|
1132
|
+
true
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def command_line?(line)
|
|
1136
|
+
command_registry.command?(line) || stats_command?(line) || recap_command?(line) || exit_command?(line)
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
# The kernel's drain at an iteration boundary: queued lines, #memory
|
|
1140
|
+
# shorthand made words as for a prompt, and saved in the history.
|
|
1141
|
+
def drain_steering
|
|
1142
|
+
@pending_input_queue.drain.map do |line|
|
|
1143
|
+
persist_recent_history(line)
|
|
1144
|
+
normalize_model_input(line)
|
|
1145
|
+
end
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# The REPL's on_event sink for Engine#run_turn: render one event. Engine
|
|
1149
|
+
# swallows on_event errors to protect the turn, so log ours instead.
|
|
1150
|
+
def handle_stream_event(event)
|
|
1151
|
+
@renderer.call(event)
|
|
1152
|
+
rescue StandardError => e
|
|
1153
|
+
Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
def emit_cancellation_notice(result)
|
|
1157
|
+
return unless result.respond_to?(:canceled?) && result.canceled?
|
|
1158
|
+
|
|
1159
|
+
reason = result.respond_to?(:cancellation_reason) ? result.cancellation_reason : nil
|
|
1160
|
+
label = cancellation_reason_label(reason)
|
|
1161
|
+
@surface.commit("\nmodel> request cancelled#{label.empty? ? "" : " (#{label})"}")
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
def cancelled_result_from(messages, reason:)
|
|
1165
|
+
KernelLoop::Result.new(
|
|
1166
|
+
output: "",
|
|
1167
|
+
conversation: clone_messages(messages),
|
|
1168
|
+
exhausted: false,
|
|
1169
|
+
pending_tool_calls: false,
|
|
1170
|
+
tool_activity: [],
|
|
1171
|
+
canceled: true,
|
|
1172
|
+
cancellation_reason: reason
|
|
1173
|
+
)
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
def cancellation_reason_label(reason)
|
|
1177
|
+
return "" if reason.nil?
|
|
1178
|
+
|
|
1179
|
+
case reason.to_sym
|
|
1180
|
+
when :ctrl_c
|
|
1181
|
+
"ctrl-c"
|
|
1182
|
+
else
|
|
1183
|
+
reason.to_s
|
|
1184
|
+
end
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
def start_thinking_spinner
|
|
1188
|
+
return unless thinking_spinner_enabled?
|
|
1189
|
+
|
|
1190
|
+
@thinking_spinner_active = true
|
|
1191
|
+
@thinking_spinner_index = 0 if @thinking_spinner_index.nil?
|
|
1192
|
+
@thinking_spinner_last_render_at = nil
|
|
1193
|
+
@thinking_waiting_since = monotonic_time
|
|
1194
|
+
render_thinking_spinner
|
|
1195
|
+
start_thinking_ticker
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
# One thread per spinner; it ends when the spinner does.
|
|
1199
|
+
def start_thinking_ticker
|
|
1200
|
+
return unless @spinner_tick_interval
|
|
1201
|
+
return if @thinking_ticker&.alive?
|
|
1202
|
+
|
|
1203
|
+
@thinking_ticker = Thread.new do
|
|
1204
|
+
loop do
|
|
1205
|
+
sleep(@spinner_tick_interval)
|
|
1206
|
+
break unless tick_thinking_spinner_on_timer
|
|
1207
|
+
end
|
|
1208
|
+
rescue StandardError
|
|
1209
|
+
nil
|
|
1210
|
+
end
|
|
1211
|
+
@thinking_ticker.report_on_exception = false
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
# Turn the spinner when no chunk did for a tick.
|
|
1215
|
+
# @return [Boolean] whether the spinner is still shown
|
|
1216
|
+
def tick_thinking_spinner_on_timer
|
|
1217
|
+
@spinner_lock.synchronize do
|
|
1218
|
+
return false unless @thinking_spinner_active
|
|
1219
|
+
|
|
1220
|
+
last = @thinking_spinner_last_render_at
|
|
1221
|
+
if last.nil? || (monotonic_time - last) >= @spinner_tick_interval
|
|
1222
|
+
@thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
|
|
1223
|
+
render_thinking_spinner
|
|
1224
|
+
end
|
|
1225
|
+
true
|
|
1226
|
+
end
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
def tick_thinking_spinner
|
|
1230
|
+
return unless @thinking_spinner_active
|
|
1231
|
+
|
|
1232
|
+
@thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
|
|
1233
|
+
render_thinking_spinner_if_due
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def refresh_thinking_spinner_status
|
|
1237
|
+
return unless @thinking_spinner_active
|
|
1238
|
+
|
|
1239
|
+
render_thinking_spinner
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
def render_thinking_spinner_if_due
|
|
1243
|
+
last = @thinking_spinner_last_render_at
|
|
1244
|
+
return render_thinking_spinner if last.nil?
|
|
1245
|
+
return if (monotonic_time - last) < thinking_render_min_interval
|
|
1246
|
+
|
|
1247
|
+
render_thinking_spinner
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
public
|
|
1251
|
+
|
|
1252
|
+
# Erase the spinner rows; nothing to do when none are shown.
|
|
1253
|
+
def finish_thinking_spinner
|
|
1254
|
+
@spinner_lock.synchronize do
|
|
1255
|
+
# Off even when the rows are gone already, so the ticker ends.
|
|
1256
|
+
@thinking_spinner_active = false
|
|
1257
|
+
@init_wait_status = nil
|
|
1258
|
+
@thinking_waiting_since = nil
|
|
1259
|
+
next unless @surface.clear_slot(:activity)
|
|
1260
|
+
|
|
1261
|
+
@thinking_spinner_last_render_at = nil
|
|
1262
|
+
end
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
private
|
|
1266
|
+
|
|
1267
|
+
def retry_spinner_status_line(frame, width)
|
|
1268
|
+
data = @retry_spinner_status || {}
|
|
1269
|
+
attempt = data[:attempt].to_i
|
|
1270
|
+
max_retries = data[:max_retries].to_i
|
|
1271
|
+
total_attempts = max_retries + 1
|
|
1272
|
+
delay = format("%.1f", data[:next_delay].to_f)
|
|
1273
|
+
error_class = data[:error_class].to_s
|
|
1274
|
+
message = "model> network error: retrying (#{attempt}/#{total_attempts} in #{delay}s) #{frame}"
|
|
1275
|
+
message += " #{error_class}" unless error_class.empty?
|
|
1276
|
+
capped = cap_preview_line(message, width)
|
|
1277
|
+
color_output? ? paint(capped, NETWORK_RETRY_SPINNER_COLOR) : capped
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
def cap_preview_line(text, width)
|
|
1281
|
+
cap_preview_text(text, thinking_preview_width(width))
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
def thinking_preview_width(width)
|
|
1285
|
+
return THINKING_PREVIEW_WIDTH if width <= 0
|
|
1286
|
+
|
|
1287
|
+
[width, THINKING_PREVIEW_WIDTH].min
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def thinking_render_min_interval
|
|
1292
|
+
value = ENV.fetch(THINKING_RENDER_INTERVAL_ENV, THINKING_RENDER_MIN_INTERVAL.to_s).to_f
|
|
1293
|
+
return THINKING_RENDER_MIN_INTERVAL unless value.positive?
|
|
1294
|
+
|
|
1295
|
+
value
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
def monotonic_time
|
|
1299
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1300
|
+
end
|
|
1301
|
+
|
|
1302
|
+
def capture_memory_tool_call(event)
|
|
1303
|
+
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
1304
|
+
memory_name = memory_name_from_tool_call(call)
|
|
1305
|
+
return false if memory_name.nil? || memory_name.empty?
|
|
1306
|
+
|
|
1307
|
+
added_to_thinking = add_unique_memory_name(:@thinking_memory_names, memory_name)
|
|
1308
|
+
add_unique_memory_name(:@session_memory_names, memory_name)
|
|
1309
|
+
@thinking_recent_memory_loaded = memory_name if added_to_thinking
|
|
1310
|
+
added_to_thinking
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def capture_thinking_tool_call(event)
|
|
1314
|
+
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
1315
|
+
name = call[:name].to_s.strip
|
|
1316
|
+
return if name.empty?
|
|
1317
|
+
|
|
1318
|
+
params = thinking_tool_params_preview(call, event[:params])
|
|
1319
|
+
text = params.empty? ? name : "#{name}(#{params})"
|
|
1320
|
+
@thinking_recent_tool_call = cap_preview_text(text, THINKING_TOOL_PREVIEW_LIMIT)
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
def thinking_tool_params_preview(call, raw_params)
|
|
1324
|
+
compact = raw_params.to_s.gsub(/\s+/, " ").strip
|
|
1325
|
+
return compact unless compact.empty?
|
|
1326
|
+
|
|
1327
|
+
tool_name = call[:name].to_s
|
|
1328
|
+
case tool_name
|
|
1329
|
+
when Tools::Execute::NAME
|
|
1330
|
+
"command=#{preview_value_for_spinner(call[:content])}"
|
|
1331
|
+
when Tools::Read::NAME
|
|
1332
|
+
"path=#{preview_value_for_spinner(call[:content])}"
|
|
1333
|
+
when Tools::Write::NAME, Tools::Edit::NAME
|
|
1334
|
+
"path=#{preview_value_for_spinner(call[:path])}"
|
|
1335
|
+
when Tools::MemoryRead::NAME
|
|
1336
|
+
parts = []
|
|
1337
|
+
name = call[:content].to_s.strip
|
|
1338
|
+
parts << "name=#{preview_value_for_spinner(name)}" unless name.empty?
|
|
1339
|
+
scope = call[:scope].to_s.strip
|
|
1340
|
+
parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
|
|
1341
|
+
parts.join(" ")
|
|
1342
|
+
when Tools::MemoryWrite::NAME
|
|
1343
|
+
parts = []
|
|
1344
|
+
path = call[:path].to_s.strip
|
|
1345
|
+
parts << "name=#{preview_value_for_spinner(path)}" unless path.empty?
|
|
1346
|
+
scope = call[:scope].to_s.strip
|
|
1347
|
+
parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
|
|
1348
|
+
parts.join(" ")
|
|
1349
|
+
else
|
|
1350
|
+
""
|
|
1351
|
+
end
|
|
1352
|
+
end
|
|
1353
|
+
|
|
1354
|
+
def preview_value_for_spinner(value)
|
|
1355
|
+
text = value.to_s.gsub(/\s+/, " ").strip
|
|
1356
|
+
return '""' if text.empty?
|
|
1357
|
+
|
|
1358
|
+
text.inspect
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
def add_unique_memory_name(ivar_name, value)
|
|
1362
|
+
names = instance_variable_get(ivar_name) || []
|
|
1363
|
+
return false if names.include?(value)
|
|
1364
|
+
|
|
1365
|
+
names << value
|
|
1366
|
+
instance_variable_set(ivar_name, names)
|
|
1367
|
+
true
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
def memory_name_from_tool_call(call)
|
|
1371
|
+
tool_name = call[:name].to_s
|
|
1372
|
+
case tool_name
|
|
1373
|
+
when Tools::MemoryRead::NAME
|
|
1374
|
+
normalize_memory_name(call[:content])
|
|
1375
|
+
when Tools::Read::NAME
|
|
1376
|
+
memory_name_from_read_path(call[:content])
|
|
1377
|
+
else
|
|
1378
|
+
nil
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
def normalize_memory_name(raw)
|
|
1383
|
+
value = raw.to_s.strip
|
|
1384
|
+
return nil if value.empty?
|
|
1385
|
+
|
|
1386
|
+
File.basename(value, ".md")
|
|
1387
|
+
end
|
|
1388
|
+
|
|
1389
|
+
def memory_name_from_read_path(raw_path)
|
|
1390
|
+
path = raw_path.to_s.strip.tr("\\", "/")
|
|
1391
|
+
return nil if path.empty?
|
|
1392
|
+
return nil unless path.match?(/memories[\/].+\.md\z/)
|
|
1393
|
+
|
|
1394
|
+
normalize_memory_name(path)
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def memory_spinner_segment
|
|
1398
|
+
segment = memory_spinner_segment_plain
|
|
1399
|
+
return "" if segment.empty?
|
|
1400
|
+
|
|
1401
|
+
color_output? ? paint(segment, MEMORY_SPINNER_COLOR) : segment
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
def memory_spinner_segment_plain
|
|
1405
|
+
names = Array(@thinking_memory_names)
|
|
1406
|
+
return "" if names.empty?
|
|
1407
|
+
|
|
1408
|
+
visible = names.first(MEMORY_SPINNER_PREVIEW_LIMIT)
|
|
1409
|
+
suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
|
|
1410
|
+
" mem: #{visible.join(', ')}#{suffix}"
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
def memory_sticky_line
|
|
1414
|
+
names = Array(@session_memory_names)
|
|
1415
|
+
muted = @engine.muted_memory_names
|
|
1416
|
+
return "" if names.empty? && muted.empty?
|
|
1417
|
+
|
|
1418
|
+
parts = []
|
|
1419
|
+
parts << "active this session: #{preview_names(names)}" unless names.empty?
|
|
1420
|
+
parts << "muted: #{preview_names(muted)}" unless muted.empty?
|
|
1421
|
+
body = "memories> #{parts.join(' · ')}"
|
|
1422
|
+
color_output? ? paint(body, MEMORY_SPINNER_COLOR) : body
|
|
1423
|
+
end
|
|
1424
|
+
|
|
1425
|
+
def preview_names(names, limit: MEMORY_STICKY_PREVIEW_LIMIT)
|
|
1426
|
+
visible = names.first(limit)
|
|
1427
|
+
suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
|
|
1428
|
+
"#{visible.join(', ')}#{suffix}"
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
def reset_thinking_memory_names
|
|
1432
|
+
@thinking_memory_names = []
|
|
1433
|
+
end
|
|
1434
|
+
|
|
1435
|
+
def reset_thinking_memory_notification
|
|
1436
|
+
@thinking_recent_memory_loaded = nil
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1439
|
+
def reset_thinking_tool_notification
|
|
1440
|
+
@thinking_recent_tool_call = nil
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
def capture_server_context_status_from_payload(payload)
|
|
1444
|
+
# The window the kernel resolved for this generation (see
|
|
1445
|
+
# :generation_started); the configured one before the first generation.
|
|
1446
|
+
window_tokens = @context_window_tokens || ContextWindow.configured.tokens
|
|
1447
|
+
normalized = ContextUsage.normalize(payload, window_tokens: window_tokens)
|
|
1448
|
+
return unless normalized
|
|
1449
|
+
|
|
1450
|
+
@latest_server_context_status = normalized
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def emit_idle_status_line
|
|
1455
|
+
return unless status_line_enabled?
|
|
1456
|
+
|
|
1457
|
+
lines = idle_status_lines(width: status_effective_width)
|
|
1458
|
+
return if lines.empty?
|
|
1459
|
+
|
|
1460
|
+
@surface.set_slot(:status, lines)
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
# ── Idle session recap ───────────────────────────────────────────────────
|
|
1464
|
+
#
|
|
1465
|
+
# The Engine's idle job emits :recap_ready once the session has been idle
|
|
1466
|
+
# for its inactivity threshold (or /recap asked). On the scheduler thread:
|
|
1467
|
+
# kept for the main thread to print at the open prompt. One collected
|
|
1468
|
+
# just as a turn started describes the chat before it.
|
|
1469
|
+
def handle_recap_ready(event)
|
|
1470
|
+
return unless event[:type] == :recap_ready
|
|
1471
|
+
return if event[:recap].to_s.strip.empty? || @engine.turn_running?
|
|
1472
|
+
|
|
1473
|
+
@pending_recap = event[:recap].to_s
|
|
1474
|
+
end
|
|
1475
|
+
|
|
1476
|
+
# Print a recap written while idle (main thread, at the open prompt).
|
|
1477
|
+
def flush_pending_recap
|
|
1478
|
+
recap = @pending_recap
|
|
1479
|
+
return unless recap
|
|
1480
|
+
|
|
1481
|
+
@pending_recap = nil
|
|
1482
|
+
@surface.commit(recap_block(recap))
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
# ── Cards and notices between turns ─────────────────────────────────────
|
|
1486
|
+
#
|
|
1487
|
+
# A card or a plugin's notice shown outside a turn is announced; on the
|
|
1488
|
+
# announcing thread it is only kept, for the main thread to print at the
|
|
1489
|
+
# open prompt (after the command that showed it). One shown during a
|
|
1490
|
+
# turn is a turn event: the turn's sink prints it where it happens
|
|
1491
|
+
# (EventRenderer; the Screen draws it above the live region).
|
|
1492
|
+
#
|
|
1493
|
+
# An anytime command's (event[:anytime]) print as it shows them: on the
|
|
1494
|
+
# main thread (the command runs at the prompt) or beside a running turn
|
|
1495
|
+
# (above the live region), else at the next flush.
|
|
1496
|
+
#
|
|
1497
|
+
# A plugin's init task (chi.init) prints a line as it starts and one
|
|
1498
|
+
# when it is done; a load warning announced before the first turn, one.
|
|
1499
|
+
# Beside a running turn they print at once; a --non-interactive run
|
|
1500
|
+
# prints only its answer.
|
|
1501
|
+
def handle_card_event(event)
|
|
1502
|
+
if INIT_EVENTS.include?(event[:type])
|
|
1503
|
+
return if @non_interactive
|
|
1504
|
+
return show_pending_item(event) if @engine.turn_running?
|
|
1505
|
+
|
|
1506
|
+
return @pending_cards << event
|
|
1507
|
+
end
|
|
1508
|
+
return unless (event[:type] == :card && !event[:in_turn]) || (event[:type] == :hook_notice && event[:between_turns])
|
|
1509
|
+
return show_pending_item(event) if event[:anytime] && (Thread.current == Thread.main || @engine.turn_running?)
|
|
1510
|
+
|
|
1511
|
+
@pending_cards << event
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
# Print the cards, notices and anytime commands' output kept since the
|
|
1515
|
+
# last flush (main thread). A card replaced later in the same batch (the
|
|
1516
|
+
# same id: btw's "thinking…", then its answer) prints once, as its last.
|
|
1517
|
+
def flush_pending_cards
|
|
1518
|
+
items = []
|
|
1519
|
+
loop { items << @pending_cards.pop(true) }
|
|
1520
|
+
rescue ThreadError
|
|
1521
|
+
items.each_with_index do |item, index|
|
|
1522
|
+
replaced = item[:type] == :card && items.drop(index + 1).any? { |later| later[:type] == :card && later[:id] == item[:id] }
|
|
1523
|
+
show_pending_item(item) unless replaced
|
|
1524
|
+
end
|
|
1525
|
+
nil
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
|
|
1529
|
+
|
|
1530
|
+
# A card, a notice, an anytime command's output (:command_output), a
|
|
1531
|
+
# plugin init task's line or a load warning.
|
|
1532
|
+
def show_pending_item(item)
|
|
1533
|
+
case item[:type]
|
|
1534
|
+
when :card then @renderer.render_card(item)
|
|
1535
|
+
when :command_output then @surface.commit(item[:text])
|
|
1536
|
+
when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
|
|
1537
|
+
when :plugin_init_started, :plugin_init_finished
|
|
1538
|
+
line = EventRenderer.init_line(item)
|
|
1539
|
+
@surface.commit(line) if line
|
|
1540
|
+
else @surface.commit(EventRenderer.hook_notice_line(item))
|
|
1541
|
+
end
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
# Resolve the recap config (on by default). Returns false when explicitly
|
|
1545
|
+
# disabled, nil when nothing is configured, or a Hash when enabled so the
|
|
1546
|
+
# Engine can build the detector. Single precedence path via the Config
|
|
1547
|
+
# registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
|
|
1548
|
+
def recap_config
|
|
1549
|
+
ConfigFile.recap_config
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
def bare_model_for(full_ref)
|
|
1553
|
+
@host_registry.bare_name(full_ref)
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
def spinner_status_line
|
|
1557
|
+
return "" unless status_line_enabled?
|
|
1558
|
+
|
|
1559
|
+
lines = spinner_status_lines
|
|
1560
|
+
lines.empty? ? "" : lines.first
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
def spinner_status_lines(width: status_effective_width)
|
|
1564
|
+
return [] unless status_line_enabled?
|
|
1565
|
+
|
|
1566
|
+
build_status_lines(scope: :spinner, width: width)
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
def sticky_status_line
|
|
1570
|
+
return "" unless status_line_enabled?
|
|
1571
|
+
|
|
1572
|
+
lines = sticky_status_lines
|
|
1573
|
+
lines.empty? ? "" : lines.first
|
|
1574
|
+
end
|
|
1575
|
+
|
|
1576
|
+
def sticky_status_lines(width: status_effective_width)
|
|
1577
|
+
return [] unless status_line_enabled?
|
|
1578
|
+
|
|
1579
|
+
build_status_lines(scope: :sticky, width: width)
|
|
1580
|
+
end
|
|
1581
|
+
|
|
1582
|
+
def idle_status_line
|
|
1583
|
+
return "" unless status_line_enabled?
|
|
1584
|
+
|
|
1585
|
+
lines = idle_status_lines
|
|
1586
|
+
lines.empty? ? "" : lines.first
|
|
1587
|
+
end
|
|
1588
|
+
|
|
1589
|
+
def idle_status_lines(width: status_effective_width)
|
|
1590
|
+
return [] unless status_line_enabled?
|
|
1591
|
+
|
|
1592
|
+
build_status_lines(scope: :idle, width: width)
|
|
1593
|
+
end
|
|
1594
|
+
|
|
1595
|
+
def build_status_line(scope:)
|
|
1596
|
+
lines = build_status_lines(scope: scope)
|
|
1597
|
+
lines.empty? ? "" : lines.first
|
|
1598
|
+
end
|
|
1599
|
+
|
|
1600
|
+
# The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
|
|
1601
|
+
def build_status_lines(scope:, width: status_effective_width)
|
|
1602
|
+
status_rows(status_segments(scope), width)
|
|
1603
|
+
end
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def status_width_mode
|
|
1607
|
+
mode = ENV.fetch(STATUS_WIDTH_MODE_ENV, STATUS_WIDTH_MODE_TERMINAL_CAP).to_s.strip.downcase
|
|
1608
|
+
return STATUS_WIDTH_MODE_FIXED if mode == STATUS_WIDTH_MODE_FIXED
|
|
1609
|
+
|
|
1610
|
+
STATUS_WIDTH_MODE_TERMINAL_CAP
|
|
1611
|
+
end
|
|
1612
|
+
|
|
1613
|
+
def status_effective_width
|
|
1614
|
+
mode = status_width_mode
|
|
1615
|
+
width = if mode == STATUS_WIDTH_MODE_FIXED
|
|
1616
|
+
status_fixed_width
|
|
1617
|
+
else
|
|
1618
|
+
[terminal_columns, status_max_width].min
|
|
1619
|
+
end
|
|
1620
|
+
width = status_fixed_width unless width.positive?
|
|
1621
|
+
width
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
def status_fixed_width
|
|
1625
|
+
env_positive_int(STATUS_FIXED_WIDTH_ENV, THINKING_PREVIEW_WIDTH)
|
|
1626
|
+
end
|
|
1627
|
+
|
|
1628
|
+
def status_max_width
|
|
1629
|
+
env_positive_int(STATUS_MAX_WIDTH_ENV, STATUS_MAX_WIDTH_DEFAULT)
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
def terminal_columns
|
|
1633
|
+
columns = begin
|
|
1634
|
+
io = IO.console
|
|
1635
|
+
io&.winsize&.[](1).to_i
|
|
1636
|
+
rescue StandardError
|
|
1637
|
+
0
|
|
1638
|
+
end
|
|
1639
|
+
return columns if columns.positive?
|
|
1640
|
+
|
|
1641
|
+
env_positive_int("COLUMNS", status_max_width)
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
def env_positive_int(key, default)
|
|
1645
|
+
value = ENV.fetch(key, default.to_s).to_i
|
|
1646
|
+
value.positive? ? value : default
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
def status_segments(scope)
|
|
1650
|
+
segments = [status_model_segment, status_server_segment].reject(&:empty?)
|
|
1651
|
+
context_segment = status_context_segment
|
|
1652
|
+
memory_segment = status_memory_segment(scope)
|
|
1653
|
+
segments << context_segment unless context_segment.empty?
|
|
1654
|
+
segments << memory_segment unless memory_segment.empty?
|
|
1655
|
+
# The session's --mute list, on the sticky and idle rows (not the spinner).
|
|
1656
|
+
muted_segment = scope == :spinner ? "" : status_memory_text(@engine.muted_memory_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")
|
|
1657
|
+
segments << muted_segment unless muted_segment.empty?
|
|
1658
|
+
segments
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
def status_model_segment
|
|
1662
|
+
served, served_for = @engine.respond_to?(:served_model) ? @engine.served_model(probe: false) : nil
|
|
1663
|
+
status_model_text(@effective_model_name, @default_model_name, served: served, served_for: served_for)
|
|
1664
|
+
end
|
|
1665
|
+
|
|
1666
|
+
def status_context_segment
|
|
1667
|
+
status_context_text(server: @latest_server_context_status, estimate: @latest_context_status)
|
|
1668
|
+
end
|
|
1669
|
+
|
|
1670
|
+
def status_memory_segment(scope)
|
|
1671
|
+
names, limit = case scope
|
|
1672
|
+
when :spinner
|
|
1673
|
+
[Array(@thinking_memory_names), MEMORY_SPINNER_PREVIEW_LIMIT]
|
|
1674
|
+
else
|
|
1675
|
+
[Array(@session_memory_names), MEMORY_STICKY_PREVIEW_LIMIT]
|
|
1676
|
+
end
|
|
1677
|
+
status_memory_text(names, limit)
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
def thinking_memory_notification_suffix
|
|
1681
|
+
memory_name = @thinking_recent_memory_loaded.to_s.strip
|
|
1682
|
+
return "" if memory_name.empty?
|
|
1683
|
+
|
|
1684
|
+
" memory_loaded: #{memory_name}"
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
def thinking_tool_notification_suffix
|
|
1688
|
+
tool_call = @thinking_recent_tool_call.to_s.strip
|
|
1689
|
+
return "" if tool_call.empty?
|
|
1690
|
+
|
|
1691
|
+
" last_tool: #{tool_call}"
|
|
1692
|
+
end
|
|
1693
|
+
|
|
1694
|
+
def thinking_notification_segments(width)
|
|
1695
|
+
return ["", ""] if width <= 0
|
|
1696
|
+
|
|
1697
|
+
memory_suffix = cap_preview_text(thinking_memory_notification_suffix, width)
|
|
1698
|
+
remaining = [width - memory_suffix.length, 0].max
|
|
1699
|
+
tool_suffix = cap_preview_text(thinking_tool_notification_suffix, remaining)
|
|
1700
|
+
[memory_suffix, tool_suffix]
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
def paint_if_present(text, code)
|
|
1704
|
+
return "" if text.to_s.empty?
|
|
1705
|
+
|
|
1706
|
+
paint(text, code)
|
|
1707
|
+
end
|
|
1708
|
+
|
|
1709
|
+
def thinking_spinner_enabled?
|
|
1710
|
+
return false unless $stdout.tty?
|
|
1711
|
+
|
|
1712
|
+
mode = ENV.fetch(THINKING_UI_ENV, THINKING_UI_SPINNER).to_s.strip.downcase
|
|
1713
|
+
return false if mode.empty? || mode == THINKING_UI_OFF || mode == "false" || mode == "0"
|
|
1714
|
+
|
|
1715
|
+
mode == THINKING_UI_SPINNER && ENV.fetch("TERM", "") != "dumb"
|
|
1716
|
+
end
|
|
1717
|
+
|
|
1718
|
+
def set_retry_spinner_status(event)
|
|
1719
|
+
@retry_spinner_status = {
|
|
1720
|
+
attempt: event[:attempt],
|
|
1721
|
+
max_retries: event[:max_retries],
|
|
1722
|
+
next_delay: event[:next_delay],
|
|
1723
|
+
error_class: event[:error_class]
|
|
1724
|
+
}
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
def clear_retry_spinner_status
|
|
1728
|
+
@retry_spinner_status = nil
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1731
|
+
def retry_spinner_status_active?
|
|
1732
|
+
@retry_spinner_status.is_a?(Hash)
|
|
1733
|
+
end
|
|
1734
|
+
|
|
1735
|
+
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1736
|
+
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
1737
|
+
# single seam the Engine-owned idle recap detector reads, so the idle clock
|
|
1738
|
+
# is identical for the REPL and any other Engine-backed UI. Chains onto any
|
|
1739
|
+
# previously-installed hook (e.g. the input prefill hook).
|
|
1740
|
+
def with_activity_hook
|
|
1741
|
+
previous_hook = Reline.pre_input_hook
|
|
1742
|
+
previous_key_handler = RelineSeam.key_handler
|
|
1743
|
+
Reline.pre_input_hook = proc do
|
|
1744
|
+
@engine.record_activity
|
|
1745
|
+
previous_hook.call if previous_hook
|
|
1746
|
+
end
|
|
1747
|
+
# Typing is activity too: a reminder waits while you type.
|
|
1748
|
+
RelineSeam.key_handler = -> { @engine.record_activity }
|
|
1749
|
+
yield
|
|
1750
|
+
ensure
|
|
1751
|
+
Reline.pre_input_hook = previous_hook
|
|
1752
|
+
RelineSeam.key_handler = previous_key_handler
|
|
1753
|
+
end
|
|
1754
|
+
|
|
1755
|
+
def render_thinking_spinner
|
|
1756
|
+
frame = THINKING_SPINNER_FRAMES[@thinking_spinner_index % THINKING_SPINNER_FRAMES.length]
|
|
1757
|
+
width = status_effective_width
|
|
1758
|
+
@thinking_line.tick
|
|
1759
|
+
# The spinner goes above the prompt, the status rows below it.
|
|
1760
|
+
@surface.set_slots(activity: thinking_spinner_status_lines(frame, width: width), status: spinner_status_lines(width: width))
|
|
1761
|
+
@thinking_spinner_last_render_at = monotonic_time
|
|
1762
|
+
end
|
|
1763
|
+
|
|
1764
|
+
# "model> waiting for the first token... 5s |" once a generation has
|
|
1765
|
+
# shown nothing for THINKING_WAIT_NOTICE_AFTER seconds, else nil.
|
|
1766
|
+
def first_token_wait_status(frame)
|
|
1767
|
+
return nil unless @thinking_waiting_since
|
|
1768
|
+
|
|
1769
|
+
waited = monotonic_time - @thinking_waiting_since
|
|
1770
|
+
return nil if waited < THINKING_WAIT_NOTICE_AFTER
|
|
1771
|
+
|
|
1772
|
+
"model> waiting for the first token... #{waited.floor}s #{frame}"
|
|
1773
|
+
end
|
|
1774
|
+
|
|
1775
|
+
def thinking_spinner_status_line(frame)
|
|
1776
|
+
lines = thinking_spinner_status_lines(frame)
|
|
1777
|
+
lines.empty? ? "" : lines.first
|
|
1778
|
+
end
|
|
1779
|
+
|
|
1780
|
+
# The spinner row, then (from a turn's 3rd tool call) its tool tally.
|
|
1781
|
+
# The spinner stops while tools run, so the tally shows while the model
|
|
1782
|
+
# generates between tool rounds.
|
|
1783
|
+
def thinking_spinner_status_lines(frame, width: status_effective_width)
|
|
1784
|
+
spinner_row_lines(frame, width: width) + tally_status_lines(width)
|
|
1785
|
+
end
|
|
1786
|
+
|
|
1787
|
+
def tally_status_lines(width)
|
|
1788
|
+
tally = @turn_tally.text(width: width)
|
|
1789
|
+
return [] unless tally
|
|
1790
|
+
|
|
1791
|
+
[color_output? ? paint(tally, 90) : tally]
|
|
1792
|
+
end
|
|
1793
|
+
|
|
1794
|
+
def tally_key(event)
|
|
1795
|
+
[event[:iteration].to_i, event[:call_index].to_i]
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
def spinner_row_lines(frame, width:)
|
|
1799
|
+
if retry_spinner_status_active?
|
|
1800
|
+
return [retry_spinner_status_line(frame, width)]
|
|
1801
|
+
end
|
|
1802
|
+
|
|
1803
|
+
return ["#{@init_wait_status} #{frame}"[0, width]] if @init_wait_status
|
|
1804
|
+
|
|
1805
|
+
base = first_token_wait_status(frame) || "model> thinking... #{frame}"
|
|
1806
|
+
available_for_notification = [width - base.length, 0].max
|
|
1807
|
+
memory_notification, tool_notification = thinking_notification_segments(available_for_notification)
|
|
1808
|
+
notification = "#{memory_notification}#{tool_notification}"
|
|
1809
|
+
base = thinking_sentence_base(frame, width - notification.length) || base
|
|
1810
|
+
|
|
1811
|
+
return ["#{base}#{notification}"] unless color_output?
|
|
1812
|
+
|
|
1813
|
+
["#{paint(base, 90)}#{paint_if_present(memory_notification, MEMORY_SPINNER_COLOR)}#{paint_if_present(tool_notification, TOOL_SPINNER_COLOR)}"]
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
# "model> thinking · <the newest sentence> |" within +room+ columns (the
|
|
1817
|
+
# web's thinking ticker, ThinkingLine), or nil before the first sentence.
|
|
1818
|
+
def thinking_sentence_base(frame, room)
|
|
1819
|
+
return nil if @thinking_waiting_since || @thinking_line.empty?
|
|
1820
|
+
|
|
1821
|
+
prefix = "model> #{@thinking_line.label} · "
|
|
1822
|
+
room -= prefix.length + frame.length + 1
|
|
1823
|
+
return nil if room < MIN_SENTENCE_ROOM
|
|
1824
|
+
|
|
1825
|
+
"#{prefix}#{@thinking_line.fit(room)} #{frame}"
|
|
1826
|
+
end
|
|
1827
|
+
|
|
1828
|
+
# ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
|
|
1829
|
+
|
|
1830
|
+
# Non-blocking observer: the turn thread emits :question_requested; we stash
|
|
1831
|
+
# it so the REPL thread (the only one that may touch Reline) can drain it
|
|
1832
|
+
# at the top of run_assist_loop without racing the completion.
|
|
1833
|
+
def handle_question_event(event)
|
|
1834
|
+
return unless event.is_a?(Hash)
|
|
1835
|
+
|
|
1836
|
+
type = event[:type] || event["type"]
|
|
1837
|
+
return unless type.to_s == "question_requested"
|
|
1838
|
+
|
|
1839
|
+
pq = event[:pending_question] || event["pending_question"] || event[:pendingQuestion]
|
|
1840
|
+
return unless pq
|
|
1841
|
+
|
|
1842
|
+
@pending_question_event = pq
|
|
1843
|
+
rescue StandardError
|
|
1844
|
+
nil
|
|
1845
|
+
end
|
|
1846
|
+
|
|
1847
|
+
def pending_question_event?
|
|
1848
|
+
!!@pending_question_event
|
|
1849
|
+
end
|
|
1850
|
+
|
|
1851
|
+
# Called from REPL thread (run_assist_loop top) — renders widget, blocks
|
|
1852
|
+
# until user selects, then answers via Engine#answer_question which wakes
|
|
1853
|
+
# the parked turn thread.
|
|
1854
|
+
def drain_pending_question?
|
|
1855
|
+
pq = @pending_question_event
|
|
1856
|
+
# Also check Engine's persisted pending (covers resume)
|
|
1857
|
+
pq ||= @engine.pending_question
|
|
1858
|
+
return false unless pq
|
|
1859
|
+
|
|
1860
|
+
# If the pending no longer matches the engine's current pending (stale
|
|
1861
|
+
# event after sync handler already answered and cleared), discard.
|
|
1862
|
+
current = @engine.pending_question
|
|
1863
|
+
if current && (pq[:id] || pq["id"]).to_s != (current[:id] || current["id"]).to_s
|
|
1864
|
+
@pending_question_event = nil
|
|
1865
|
+
return false
|
|
1866
|
+
end
|
|
1867
|
+
if current.nil? && @pending_question_event
|
|
1868
|
+
# Stale stash where engine already cleared (sync path completed)
|
|
1869
|
+
@pending_question_event = nil
|
|
1870
|
+
return false
|
|
1871
|
+
end
|
|
1872
|
+
|
|
1873
|
+
@pending_question_event = nil
|
|
1874
|
+
result = render_question_widget(pq)
|
|
1875
|
+
return false unless result
|
|
1876
|
+
|
|
1877
|
+
# result is already answered via Engine#answer_question in render_question_widget
|
|
1878
|
+
true
|
|
1879
|
+
rescue StandardError => e
|
|
1880
|
+
Log.warn(:repl, "question_drain_failed", echo: "[ask_user_question] drain failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
1881
|
+
false
|
|
1882
|
+
end
|
|
1883
|
+
|
|
1884
|
+
# The choices wait in the notes slot, laid out for the rows it gets, and
|
|
1885
|
+
# the answer is a line at the ? prompt. Once the question closes, one
|
|
1886
|
+
# line (the question and what became of it) stays in the scrollback.
|
|
1887
|
+
def render_question_widget(pending)
|
|
1888
|
+
prompt = QuestionPrompt.new(pending)
|
|
1889
|
+
|
|
1890
|
+
# Ensure spinner cleared and terminal in known state (same as reminder mute handling)
|
|
1891
|
+
finish_thinking_spinner rescue nil
|
|
1892
|
+
choices = prompt.slot(paint: method(:paint))
|
|
1893
|
+
|
|
1894
|
+
question_prompt = paint(QUESTION_PROMPT, 33)
|
|
1895
|
+
unless @repl_input
|
|
1896
|
+
@surface.set_slot(:notes, choices)
|
|
1897
|
+
return answer_question_widget(prompt) { read_choice_line(question_prompt) }
|
|
1898
|
+
end
|
|
1899
|
+
|
|
1900
|
+
# On a terminal the prompt is open (a turn runs with it): it turns into
|
|
1901
|
+
# the ? prompt for the answer. Only a line submitted there answers,
|
|
1902
|
+
# never one typed before the question came. The choices show once the
|
|
1903
|
+
# typed text is out of the prompt.
|
|
1904
|
+
@repl_input.ask(question_prompt) do |answers|
|
|
1905
|
+
@surface.set_slot(:notes, choices)
|
|
1906
|
+
answer_question_widget(prompt) { take_open_prompt_answer(answers) }
|
|
1907
|
+
end
|
|
1908
|
+
ensure
|
|
1909
|
+
@surface.clear_slot(:notes)
|
|
1910
|
+
end
|
|
1911
|
+
|
|
1912
|
+
# Loop until a valid selection or a cancel. The block reads one answer:
|
|
1913
|
+
# a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
|
|
1914
|
+
# was cancelled).
|
|
1915
|
+
def answer_question_widget(prompt)
|
|
1916
|
+
loop do
|
|
1917
|
+
raw = begin
|
|
1918
|
+
yield
|
|
1919
|
+
rescue Interrupt
|
|
1920
|
+
nil
|
|
1921
|
+
end
|
|
1922
|
+
raw = raw.to_s.strip unless raw.nil? || raw == :canceled
|
|
1923
|
+
if raw.nil? || raw == :canceled || raw.empty?
|
|
1924
|
+
# Empty, EOF / Ctrl-D / Ctrl-C -> cancel (an approval: denied)
|
|
1925
|
+
@engine.cancel_question("user") rescue nil
|
|
1926
|
+
outcome = if raw == :canceled then "(turn cancelled)"
|
|
1927
|
+
elsif prompt.approval? then "(denied)"
|
|
1928
|
+
else "(cancelled)"
|
|
1929
|
+
end
|
|
1930
|
+
close_question_widget(prompt, outcome)
|
|
1931
|
+
return false
|
|
1932
|
+
end
|
|
1933
|
+
|
|
1934
|
+
answer = prompt.parse(raw)
|
|
1935
|
+
@surface.commit(answer.note) if answer.note
|
|
1936
|
+
unless answer.ok?
|
|
1937
|
+
# The read left no echo: the line shows above its error.
|
|
1938
|
+
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
|
|
1939
|
+
@surface.commit(answer.error)
|
|
1940
|
+
next
|
|
1941
|
+
end
|
|
1942
|
+
|
|
1943
|
+
begin
|
|
1944
|
+
@engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform)
|
|
1945
|
+
close_question_widget(prompt, prompt.answer_text(answer))
|
|
1946
|
+
return true
|
|
1947
|
+
rescue ArgumentError => e
|
|
1948
|
+
@surface.commit("Invalid: #{e.message}. Try again.")
|
|
1949
|
+
next
|
|
1950
|
+
rescue StandardError => e
|
|
1951
|
+
close_question_widget(prompt, "(error: #{e.message})")
|
|
1952
|
+
return false
|
|
1953
|
+
end
|
|
1954
|
+
end
|
|
1955
|
+
end
|
|
1956
|
+
|
|
1957
|
+
def close_question_widget(prompt, outcome)
|
|
1958
|
+
@surface.clear_slot(:notes)
|
|
1959
|
+
@surface.commit(prompt.summary(outcome, paint: method(:paint)))
|
|
1960
|
+
end
|
|
1961
|
+
|
|
1962
|
+
# A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
|
|
1963
|
+
def read_choice_line(question_prompt)
|
|
1964
|
+
@surface.set_slot(:editor, [question_prompt])
|
|
1965
|
+
$stdin.gets
|
|
1966
|
+
end
|
|
1967
|
+
|
|
1968
|
+
# The next line submitted at the open prompt (?), nil for Ctrl-D
|
|
1969
|
+
# or a Ctrl-C with no turn running, or :canceled when Ctrl-C cancels the
|
|
1970
|
+
# running turn first (the read goes on; the question closes).
|
|
1971
|
+
def take_open_prompt_answer(answers)
|
|
1972
|
+
controller = @active_cancel_controller
|
|
1973
|
+
loop do
|
|
1974
|
+
kind, line = answers.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
|
|
1975
|
+
return kind == :line ? line : nil if kind
|
|
1976
|
+
return :canceled if controller&.cancelled?
|
|
1977
|
+
end
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
# Process a prompt through the kernel loop and return the model response.
|
|
1981
|
+
# Delegates to the internal Engine instance.
|
|
1982
|
+
def process_prompt_through_kernel(session, prompt)
|
|
1983
|
+
@engine.process_prompt_through_kernel(session, prompt)
|
|
1984
|
+
end
|
|
1985
|
+
|
|
1986
|
+
# Write an agent response to the session output directory.
|
|
1987
|
+
def write_session_output(output_dir, response)
|
|
1988
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
1989
|
+
File.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
|
|
1990
|
+
end
|
|
1991
|
+
end
|
|
1992
|
+
end
|