samagotchi 0.152.0 → 0.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +2 -3
- data/bin/chi +23 -24
- data/docs/architecture.md +30 -33
- data/docs/broadcast.md +5 -5
- data/docs/bundle-authoring.md +1 -1
- data/docs/bundles.md +93 -12
- data/docs/cli.md +83 -99
- data/docs/configuration.md +46 -40
- data/docs/context.md +3 -3
- data/docs/desktop.md +1 -1
- data/docs/guardrails.md +15 -15
- data/docs/hooks.md +13 -12
- data/docs/internals/anthropic.md +3 -3
- data/docs/internals/attached-context.md +5 -2
- data/docs/internals/background-tasks.md +3 -4
- data/docs/internals/block-renderers.md +4 -0
- data/docs/internals/bridge.md +3 -3
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +42 -12
- data/docs/internals/objectives.md +6 -5
- data/docs/internals/participants.md +8 -8
- data/docs/internals/prompt-caching.md +8 -4
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-frontend.md +20 -17
- data/docs/internals/web-push.md +1 -1
- data/docs/memory.md +2 -2
- data/docs/plugins.md +52 -43
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +25 -25
- data/docs/sub-agent.md +2 -2
- data/docs/system-one.md +1 -1
- data/docs/testing.md +18 -8
- data/lib/samagotchi/answer_text.rb +34 -0
- data/lib/samagotchi/bridge.rb +43 -32
- data/lib/samagotchi/bridge_client.rb +10 -9
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/broadcast/triage.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +5 -5
- data/lib/samagotchi/bundle_command/try/command.rb +1 -1
- data/lib/samagotchi/bundle_command/try/run.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
- data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
- data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
- data/lib/samagotchi/child_move.rb +3 -3
- data/lib/samagotchi/commands/registry.rb +6 -6
- data/lib/samagotchi/composer_route.rb +4 -5
- data/lib/samagotchi/config.rb +14 -12
- data/lib/samagotchi/context_candidate_choice.rb +1 -1
- data/lib/samagotchi/context_candidates.rb +4 -3
- data/lib/samagotchi/continue_offer.rb +1 -1
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/degrade.rb +75 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
- data/lib/samagotchi/engine/cards.rb +87 -0
- data/lib/samagotchi/engine/context_judge.rb +138 -0
- data/lib/samagotchi/engine/events.rb +131 -0
- data/lib/samagotchi/engine/guardrails.rb +87 -0
- data/lib/samagotchi/engine/hooks.rb +119 -0
- data/lib/samagotchi/engine/messages.rb +124 -0
- data/lib/samagotchi/engine/model_settings.rb +127 -0
- data/lib/samagotchi/engine/models.rb +261 -0
- data/lib/samagotchi/engine/objectives.rb +116 -0
- data/lib/samagotchi/engine/participants.rb +86 -0
- data/lib/samagotchi/engine/plugins.rb +208 -0
- data/lib/samagotchi/engine/questions.rb +98 -0
- data/lib/samagotchi/engine/recap.rb +130 -0
- data/lib/samagotchi/engine/session.rb +131 -0
- data/lib/samagotchi/engine/side_requests.rb +135 -0
- data/lib/samagotchi/engine/steering.rb +134 -0
- data/lib/samagotchi/engine/toolkit.rb +269 -0
- data/lib/samagotchi/engine/turn_end.rb +180 -0
- data/lib/samagotchi/engine/turn_helpers.rb +160 -0
- data/lib/samagotchi/engine/verify.rb +153 -0
- data/lib/samagotchi/engine.rb +63 -2642
- data/lib/samagotchi/grants.rb +575 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -2
- data/lib/samagotchi/guardrails/context.rb +2 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
- data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
- data/lib/samagotchi/hook_routes.rb +3 -3
- data/lib/samagotchi/idle_recap.rb +4 -5
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +10 -11
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm_context_command.rb +3 -2
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/log.rb +7 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_path.rb +1 -1
- data/lib/samagotchi/message_parts.rb +3 -2
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +18 -8
- data/lib/samagotchi/objectives.rb +24 -3
- data/lib/samagotchi/objectives_check.rb +8 -27
- data/lib/samagotchi/objectives_command.rb +4 -69
- data/lib/samagotchi/objectives_edit.rb +87 -0
- data/lib/samagotchi/output_formatter.rb +7 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/parent_answers_command.rb +5 -4
- data/lib/samagotchi/participant_exposure.rb +42 -0
- data/lib/samagotchi/participant_mode.rb +1 -1
- data/lib/samagotchi/participant_name.rb +1 -1
- data/lib/samagotchi/participants.rb +17 -17
- data/lib/samagotchi/participants_watch.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/context.rb +21 -7
- data/lib/samagotchi/plugin/peer.rb +2 -1
- data/lib/samagotchi/plugin/sessions.rb +6 -4
- data/lib/samagotchi/plugin_tasks.rb +1 -2
- data/lib/samagotchi/private_files.rb +29 -0
- data/lib/samagotchi/project_env.rb +2 -2
- data/lib/samagotchi/question_desk.rb +8 -7
- data/lib/samagotchi/recap_command.rb +2 -1
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/recap_store.rb +1 -1
- data/lib/samagotchi/redact.rb +86 -0
- data/lib/samagotchi/reminder_queue.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +2 -2
- data/lib/samagotchi/review_start.rb +1 -1
- data/lib/samagotchi/run_options.rb +6 -6
- data/lib/samagotchi/self_report.rb +42 -39
- data/lib/samagotchi/send_command/busy.rb +1 -1
- data/lib/samagotchi/send_command.rb +4 -1
- data/lib/samagotchi/session.rb +11 -2
- data/lib/samagotchi/session_access/contract.rb +3 -3
- data/lib/samagotchi/session_access/local.rb +9 -9
- data/lib/samagotchi/session_access/remote.rb +17 -7
- data/lib/samagotchi/session_access.rb +4 -4
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +8 -6
- data/lib/samagotchi/session_commands.rb +45 -48
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +4 -4
- data/lib/samagotchi/session_manager.rb +59 -36
- data/lib/samagotchi/session_metrics.rb +2 -2
- data/lib/samagotchi/session_model_settings.rb +3 -2
- data/lib/samagotchi/session_participants.rb +1 -1
- data/lib/samagotchi/session_retention.rb +3 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/system_prompt.rb +102 -109
- data/lib/samagotchi/task_watch.rb +1 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
- data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
- data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
- data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
- data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
- data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +14 -16
- data/lib/samagotchi/toolkit.rb +8 -5
- data/lib/samagotchi/toolkit_binding.rb +30 -5
- data/lib/samagotchi/toolkit_command.rb +2 -1
- data/lib/samagotchi/toolkit_report_command.rb +9 -5
- data/lib/samagotchi/toolkit_suggest.rb +12 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -10
- data/lib/samagotchi/tools/deferred_tools.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +3 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/objectives_tool.rb +2 -2
- data/lib/samagotchi/tools/registry.rb +44 -20
- data/lib/samagotchi/tools/send_note.rb +2 -1
- data/lib/samagotchi/tools/tool_index.rb +27 -21
- data/lib/samagotchi/turn_endings.rb +2 -2
- data/lib/samagotchi/turn_flow.rb +4 -5
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +39 -50
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_review.rb +5 -5
- data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
- data/lib/samagotchi/web/participant_view.rb +2 -82
- data/lib/samagotchi/web/participants_admin.rb +1 -1
- data/lib/samagotchi/web/public/app.js +61 -357
- data/lib/samagotchi/web/public/block_host.js +35 -8
- data/lib/samagotchi/web/public/block_renderers.css +9 -5
- data/lib/samagotchi/web/public/chains.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer_mode.js +110 -0
- data/lib/samagotchi/web/public/format.js +3 -3
- data/lib/samagotchi/web/public/owner_claim.js +81 -0
- data/lib/samagotchi/web/public/owner_verify.js +112 -0
- data/lib/samagotchi/web/public/session_fields.js +3 -3
- data/lib/samagotchi/web/public/strip_grip.js +3 -3
- data/lib/samagotchi/web/public/strip_hidden.js +65 -0
- data/lib/samagotchi/web/public/version_notices.js +132 -0
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +12 -8
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +2 -2
- data/lib/samagotchi/web/session_show.rb +2 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +106 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- metadata +36 -6
- data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
- data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
- data/lib/samagotchi/web/grants.rb +0 -575
- data/lib/samagotchi/web/participant_exposure.rb +0 -44
- data/lib/samagotchi/web/private_files.rb +0 -31
|
@@ -2,18 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
-
require "io/console"
|
|
6
|
-
require "reline"
|
|
7
5
|
|
|
8
6
|
require_relative "client_id"
|
|
9
7
|
require_relative "iteration_limit"
|
|
10
8
|
require_relative "model_profile"
|
|
11
|
-
require_relative "turn_note"
|
|
12
9
|
require_relative "turn_checkpoint"
|
|
13
10
|
require_relative "config"
|
|
14
11
|
require_relative "context_candidates"
|
|
15
|
-
require_relative "context_note"
|
|
16
|
-
require_relative "cancellation_controller"
|
|
17
12
|
require_relative "llm/errors"
|
|
18
13
|
require_relative "host_registry"
|
|
19
14
|
require_relative "session"
|
|
@@ -21,26 +16,15 @@ require_relative "owner_lock"
|
|
|
21
16
|
require_relative "bridge/card_store"
|
|
22
17
|
require_relative "bridge/pending_card"
|
|
23
18
|
require_relative "engine"
|
|
24
|
-
require_relative "guardrails/parent_approvals"
|
|
25
|
-
require_relative "tools/memory"
|
|
26
|
-
require_relative "output_formatter"
|
|
27
19
|
require_relative "turn_flow"
|
|
28
20
|
require_relative "session_commands"
|
|
29
21
|
require_relative "composer_route"
|
|
30
|
-
require_relative "
|
|
31
|
-
require_relative "terminal_ui/between_turns"
|
|
32
|
-
require_relative "terminal_ui/event_renderer"
|
|
22
|
+
require_relative "degrade"
|
|
33
23
|
require_relative "terminal_ui/formatting"
|
|
34
|
-
require_relative "terminal_ui/gap_clock"
|
|
35
|
-
require_relative "terminal_ui/input_support"
|
|
36
24
|
require_relative "terminal_ui/image_input"
|
|
37
25
|
require_relative "terminal_ui/plain_surface"
|
|
38
|
-
require_relative "terminal_ui/live_region"
|
|
39
26
|
require_relative "terminal_ui/one_shot_sink"
|
|
40
27
|
require_relative "terminal_ui/question_prompt"
|
|
41
|
-
require_relative "terminal_ui/repl_input"
|
|
42
|
-
require_relative "terminal_ui/status_row"
|
|
43
|
-
require_relative "children_status"
|
|
44
28
|
require_relative "log"
|
|
45
29
|
|
|
46
30
|
module Samagotchi
|
|
@@ -48,56 +32,34 @@ module Samagotchi
|
|
|
48
32
|
# files unloaded until the delegate/session path needs them.
|
|
49
33
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
50
34
|
|
|
51
|
-
# The
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# attached
|
|
35
|
+
# The one-shot host: `chi -p TEXT --non-interactive` (`chi scratch`'s and
|
|
36
|
+
# `--resume ID`'s too) runs that one turn in this process, its answer
|
|
37
|
+
# alone on stdout, and exits. It owns the session for the turn (an
|
|
38
|
+
# OwnerLock of kind "tui") and its Engine. Every interactive terminal is
|
|
39
|
+
# the attached one (TerminalUI::AttachLauncher, AttachedLoop), a client
|
|
40
|
+
# of the session's worker; the namespace keeps the terminal's parts.
|
|
56
41
|
class TerminalUI
|
|
57
42
|
include Formatting
|
|
58
|
-
include InputSupport
|
|
59
43
|
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
# the notes slot).
|
|
63
|
-
QUESTION_PROMPT = "? "
|
|
64
|
-
REMINDER_PENDING_POLL_INTERVAL = 0.5
|
|
65
|
-
# What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
|
|
66
|
-
COMMAND_BUSY = "busy: wait for the turn to end"
|
|
67
|
-
ROLLBACK_BUSY = "busy: Ctrl-C the turn first, then !rollback"
|
|
68
|
-
# A command typed during a turn that runs after it (/model X, !cmd).
|
|
69
|
-
COMMAND_QUEUED = "(queued: runs after this turn)"
|
|
70
|
-
# /detach is the attached terminal's; the REPL owns its session.
|
|
71
|
-
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
72
|
-
# /unqueue is the attached terminal's (its worker's queued input files).
|
|
73
|
-
REPL_UNQUEUE_NOTE = "(/unqueue is the attached terminal's; here a line queued with /queue TEXT runs after the turn)"
|
|
74
|
-
# A participant's turns run on the session's worker, which an attached
|
|
75
|
-
# terminal has and this REPL doesn't.
|
|
76
|
-
REPL_SHARE_NOTE = "(/share and /participants are the attached terminal's: a participant's turns run on the " \
|
|
77
|
-
"session's worker; chi share does the same from a shell)"
|
|
78
|
-
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
79
|
-
|
|
80
|
-
# Raised when another process (a `chi web` worker or another chi) owns the
|
|
81
|
-
# session this TUI was asked to run.
|
|
44
|
+
# Raised when another process (a worker or another chi) owns the session
|
|
45
|
+
# this run was asked to use.
|
|
82
46
|
class SessionBusy < StandardError; end
|
|
83
47
|
# --resume with an id that has no saved session.
|
|
84
48
|
class SessionNotFound < StandardError; end
|
|
85
49
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
attr_reader :between_turns
|
|
90
|
-
# The session's Engine: the REPL drives it inline (#run_engine_turn).
|
|
50
|
+
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
51
|
+
|
|
52
|
+
# The session's Engine.
|
|
91
53
|
# @return [Engine]
|
|
92
54
|
attr_reader :engine
|
|
93
55
|
|
|
94
|
-
# ── System prompts (delegated to Engine) ─────────────────────────────────
|
|
95
56
|
def self.system_prompt_for(profile)
|
|
96
57
|
Engine.system_prompt_for(profile)
|
|
97
58
|
end
|
|
98
59
|
|
|
60
|
+
# @param prompt [String, nil] the turn's prompt (-p); nil: nothing runs
|
|
99
61
|
# @param scratch [Boolean] `chi scratch`: a new session that is deleted
|
|
100
|
-
# however the
|
|
62
|
+
# however the run ends, saves no memories and writes no recap
|
|
101
63
|
# @param llm_context [Hash{Symbol => String}] --llm-context* as typed
|
|
102
64
|
# (LLMContextOverride.update): set on the new or resumed session
|
|
103
65
|
# @param thinking [String, nil] --thinking as typed: the new or resumed
|
|
@@ -114,10 +76,10 @@ module Samagotchi
|
|
|
114
76
|
# @param parent_answers [String, nil] --parent-answers: a new session's
|
|
115
77
|
# own guardrails.parent_answers; a resumed one keeps its own (named in
|
|
116
78
|
# +toolkit_flags+ then)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
79
|
+
# @param surface [#commit, nil] where the answer goes (stdout by default)
|
|
80
|
+
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, model_name: nil,
|
|
81
|
+
memories: [], muted_memories: [], surface: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
82
|
+
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], settings: nil, parent_answers: nil)
|
|
121
83
|
@scratch = scratch
|
|
122
84
|
@parent_answers = parent_answers
|
|
123
85
|
@toolkit = toolkit
|
|
@@ -135,7 +97,7 @@ module Samagotchi
|
|
|
135
97
|
@host_registry.client_override = client if client
|
|
136
98
|
@client = @host_registry.resolve(@effective_model_name).client
|
|
137
99
|
# Own the session before loading it, so a worker can't write a turn
|
|
138
|
-
# between the load and the lock that this
|
|
100
|
+
# between the load and the lock that this run would later save over.
|
|
139
101
|
# An unknown id is refused before the claim, which would leave an
|
|
140
102
|
# empty session dir behind.
|
|
141
103
|
if session_id && !Session.exist?(session_id)
|
|
@@ -165,8 +127,6 @@ module Samagotchi
|
|
|
165
127
|
# The model it starts on names a configured host (a bad default.model
|
|
166
128
|
# doesn't matter when --model or the session picks another).
|
|
167
129
|
ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
|
|
168
|
-
@no_default_input = no_default_input
|
|
169
|
-
@non_interactive = non_interactive
|
|
170
130
|
# --memory and --mute, with a resumed session's own lists first: a
|
|
171
131
|
# session started attached with mutes keeps them here.
|
|
172
132
|
@requested_memories = Array(memories)
|
|
@@ -177,36 +137,14 @@ module Samagotchi
|
|
|
177
137
|
@muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
|
|
178
138
|
@appended_prompt_files = (@resume_session.appended_prompt_files + @appended_prompt_files).uniq
|
|
179
139
|
end
|
|
180
|
-
# Every terminal write goes through the surface. The REPL swaps in a
|
|
181
|
-
# live region when the terminal can show one (#assist_loop), unless it
|
|
182
|
-
# was given a surface to draw on.
|
|
183
140
|
@surface = surface || PlainSurface.new
|
|
184
|
-
@surface_given = !surface.nil?
|
|
185
|
-
# The turn view is the attached TUI's, and so is the status row; both
|
|
186
|
-
# move to the live region with the surface (#use_surface). A
|
|
187
|
-
# spinner_tick_interval of nil: no ticker thread, and a spinner_clock
|
|
188
|
-
# fixes the frames (specs that compare exact frames).
|
|
189
|
-
view_options = { tick_interval: spinner_tick_interval }
|
|
190
|
-
view_options[:clock] = spinner_clock if spinner_clock
|
|
191
|
-
@view = AttachedView.new(@surface, **view_options)
|
|
192
|
-
@status_row = StatusRow.new(@surface)
|
|
193
|
-
@renderer = EventRenderer.new(@view)
|
|
194
|
-
# Cards, notices and a recap announced between turns, printed by the
|
|
195
|
-
# main thread at the open prompt (#poll_input_with_reminder_check).
|
|
196
|
-
# The session's last activity, for the gap dividers (+now+: the wall
|
|
197
|
-
# clock, a Time).
|
|
198
|
-
@gaps = GapClock.new(now: now)
|
|
199
|
-
@between_turns = BetweenTurns.new(surface: @surface, view: @view, renderer: @renderer,
|
|
200
|
-
turn_running: -> { @engine.turn_running? }, quiet: @non_interactive,
|
|
201
|
-
gaps: @gaps)
|
|
202
|
-
@render_event = ->(event) { handle_stream_event(event) }
|
|
203
141
|
@engine = Engine.new(
|
|
204
142
|
client: client,
|
|
205
143
|
host_registry: @host_registry,
|
|
206
144
|
profile: profile,
|
|
207
145
|
session_id: session_id,
|
|
208
|
-
no_interrupt:
|
|
209
|
-
# nil: the config default, unchecked here (the model the
|
|
146
|
+
no_interrupt: true,
|
|
147
|
+
# nil: the config default, unchecked here (the model the run starts
|
|
210
148
|
# on is checked above, and --model may make the default moot). With
|
|
211
149
|
# no default, the model it runs (the switch below is then skipped).
|
|
212
150
|
model_name: no_default ? @effective_model_name : nil,
|
|
@@ -214,30 +152,13 @@ module Samagotchi
|
|
|
214
152
|
memories: @requested_memories,
|
|
215
153
|
muted_memories: @muted_memory_names,
|
|
216
154
|
appended_prompt_files: @appended_prompt_files,
|
|
217
|
-
scratch: scratch
|
|
218
|
-
reminders: {
|
|
219
|
-
callback: lambda { |due_names|
|
|
220
|
-
# When a reminder is due, queue a synthetic turn that
|
|
221
|
-
# run_assist_loop checks before read_input. The synthetic turn
|
|
222
|
-
# uses an empty prompt so the agent can see
|
|
223
|
-
# [SYSTEM: REMINDERS DUE <local time>] and act on them.
|
|
224
|
-
@engine.note_due_reminders(due_names)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
155
|
+
scratch: scratch
|
|
227
156
|
)
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
@engine.next_turn_waiting = -> { true } if @non_interactive
|
|
233
|
-
# A warm-up never pays off while a line typed ahead at the open prompt
|
|
234
|
-
# (or a left-over steer) already forms the next turn: only prefill when
|
|
235
|
-
# the prompt is empty. The REPL-only check composes with the one above.
|
|
236
|
-
next_waiting = @engine.next_turn_waiting
|
|
237
|
-
@engine.next_turn_waiting = -> { @repl_input&.waiting_input? || next_waiting&.call }
|
|
157
|
+
# No one is there to answer: an approval denies.
|
|
158
|
+
@engine.interface = :non_interactive
|
|
159
|
+
# The run exits after its turn: no turn-end warm-up.
|
|
160
|
+
@engine.next_turn_waiting = -> { true }
|
|
238
161
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
239
|
-
# A recap written while a continue offer waits says the turn stopped
|
|
240
|
-
# unfinished. (Answering at the prompt is typing: activity already.)
|
|
241
162
|
@engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
|
|
242
163
|
@commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
|
|
243
164
|
registry: @engine.command_registry)
|
|
@@ -245,48 +166,26 @@ module Samagotchi
|
|
|
245
166
|
# kernel profile) without persisting the default. Skipped when there's
|
|
246
167
|
# no config default (the Engine was built on the session model).
|
|
247
168
|
@engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if !no_default && @effective_model_name != @default_model_name
|
|
248
|
-
#
|
|
249
|
-
# detector itself is Engine-owned (see Engine#recap) and opt-in.
|
|
250
|
-
@recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
|
|
251
|
-
@question_handle = @engine.subscribe(observer: ->(event) { handle_question_event(event) })
|
|
252
|
-
@card_handle = @engine.subscribe(observer: ->(event) { @between_turns.observe(event) })
|
|
253
|
-
# The REPL renders events from here on: the load warnings and notices
|
|
254
|
-
# now (at the first prompt), and the plugins' slow setup in the
|
|
255
|
-
# background. A --non-interactive run leaves both to its turn.
|
|
256
|
-
unless @non_interactive
|
|
257
|
-
@engine.announce_load_events!
|
|
258
|
-
@engine.start_init_tasks!
|
|
259
|
-
end
|
|
260
|
-
# Synchronous TUI handler for in-turn ask_user_question: the turn thread IS the
|
|
261
|
-
# REPL thread (run_engine_turn runs Engine#run_turn inline), so we must render
|
|
262
|
-
# and collect input on the SAME thread without parking on a second thread.
|
|
169
|
+
# ask_user_question in the turn: no one can answer it here.
|
|
263
170
|
@engine.set_question_sync_handler do |pending|
|
|
264
|
-
|
|
265
|
-
# which Engine.request_question reads back once this handler returns.
|
|
266
|
-
render_question_widget(pending)
|
|
171
|
+
dismiss_unaskable_question(QuestionPrompt.new(pending))
|
|
267
172
|
nil
|
|
268
173
|
end
|
|
269
174
|
end
|
|
270
175
|
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
#
|
|
278
|
-
# turn failed, :empty_answer when a -p --non-interactive turn ended
|
|
279
|
-
# with no answer, :iteration_limit when the iteration limit cut that
|
|
280
|
-
# turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
|
|
281
|
-
# cancelled that turn (bin/chi exits 130)
|
|
176
|
+
# Run the -p prompt as one turn (a session command as that command),
|
|
177
|
+
# then return; with no prompt, nothing (no session is made).
|
|
178
|
+
# @return [Symbol, nil] :turn_failed when the turn failed or the command
|
|
179
|
+
# was refused, :empty_answer when the turn ended with no answer,
|
|
180
|
+
# :iteration_limit when the iteration limit cut it short (bin/chi exits
|
|
181
|
+
# 1 on all three), :interrupted when Ctrl-C cancelled it (bin/chi
|
|
182
|
+
# exits 130)
|
|
282
183
|
def run
|
|
283
|
-
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
#
|
|
287
|
-
|
|
288
|
-
# session is made (bin/chi exits 1).
|
|
289
|
-
return refuse_one_shot_ui_command if @non_interactive && SessionCommands.ui_only_entry(@prompt)
|
|
184
|
+
return if @prompt.nil?
|
|
185
|
+
# A UI-only command (/stats, /exit …) isn't run without a terminal,
|
|
186
|
+
# and isn't a prompt either: refused as chi send refuses it, before
|
|
187
|
+
# any session is made (bin/chi exits 1).
|
|
188
|
+
return refuse_one_shot_ui_command if SessionCommands.ui_only_entry(@prompt)
|
|
290
189
|
|
|
291
190
|
session = @resume_session || @engine.store_model!(Session.new_session(
|
|
292
191
|
mode: "assist",
|
|
@@ -310,85 +209,20 @@ module Samagotchi
|
|
|
310
209
|
warn_toolkit_flags_ignored(session) if @resume_session
|
|
311
210
|
claim_session!(session.id) unless @owner_lock
|
|
312
211
|
# A turn a worker died in (TurnCheckpoint): ended as canceled, with its
|
|
313
|
-
# note, before this
|
|
212
|
+
# note, before this run's turn appends after it.
|
|
314
213
|
session.save if @resume_session && TurnCheckpoint.settle(session, session_dir: Session.session_dir(session.id))
|
|
315
214
|
keep_cards(session)
|
|
316
|
-
# Saved at once, so a process killed before its
|
|
317
|
-
#
|
|
215
|
+
# Saved at once, so a process killed before its turn leaves a file
|
|
216
|
+
# the sweep knows to delete.
|
|
318
217
|
session.save if @scratch
|
|
319
218
|
# Attach before building the prompt so it can name the session id.
|
|
320
219
|
@engine.session = session
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
# As typed at the prompt: a session command (/models, !ls) runs as
|
|
324
|
-
# the command, not as a turn.
|
|
325
|
-
return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
|
|
326
|
-
|
|
327
|
-
if @prompt && @non_interactive
|
|
328
|
-
# A /cut TEXT / /queue TEXT prompt runs its text as the turn's
|
|
329
|
-
# prompt, the same as the idle REPL does (ComposerRoute.steer_choice);
|
|
330
|
-
# a bare /cut / /queue (no text) is refused above as a UI command,
|
|
331
|
-
# so only the text here. The images are the text's, not the words'.
|
|
332
|
-
one_shot_prompt =
|
|
333
|
-
if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
|
|
334
|
-
choice.last
|
|
335
|
-
else
|
|
336
|
-
@prompt
|
|
337
|
-
end
|
|
338
|
-
# Headless / CI mode: run directly without TTY rendering.
|
|
339
|
-
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
340
|
-
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
341
|
-
sink = OneShotSink.new
|
|
342
|
-
save_before_first_turn(session)
|
|
343
|
-
attach_mentioned_context(session, one_shot_prompt)
|
|
344
|
-
max_iterations = IterationLimit.for(no_interrupt: true)
|
|
345
|
-
begin
|
|
346
|
-
result = @engine.run_turn(
|
|
347
|
-
session,
|
|
348
|
-
one_shot_prompt,
|
|
349
|
-
on_event: sink,
|
|
350
|
-
max_iterations: max_iterations,
|
|
351
|
-
cancel_controller: nil,
|
|
352
|
-
images: ImageInput.extract(one_shot_prompt)
|
|
353
|
-
)
|
|
354
|
-
rescue LLM::ProviderError, ImageStore::Error => e
|
|
355
|
-
return one_shot_failed(session, e)
|
|
356
|
-
rescue Interrupt
|
|
357
|
-
return one_shot_interrupted(session)
|
|
358
|
-
end
|
|
359
|
-
# No answer (the empty-answer retries used up): no text, or the chat
|
|
360
|
-
# loop's placeholder (LLM::ModelResult#empty_answer?).
|
|
361
|
-
if result.empty_answer?
|
|
362
|
-
# Nothing to print: say so on stderr and fail (bin/chi exits 1),
|
|
363
|
-
# so a script that pipes the answer doesn't take the silence for one.
|
|
364
|
-
session.save
|
|
365
|
-
warn EMPTY_ANSWER_ERROR
|
|
366
|
-
sink.flush
|
|
367
|
-
return :empty_answer
|
|
368
|
-
end
|
|
369
|
-
@surface.commit(result.output)
|
|
370
|
-
session.save
|
|
371
|
-
# The after_turn hooks' notices, after the answer as the REPL shows them.
|
|
372
|
-
sink.flush
|
|
373
|
-
# The limit cut the turn short: what came so far is no answer, so say
|
|
374
|
-
# so and fail (bin/chi exits 1), as the REPL's "iteration limit
|
|
375
|
-
# reached" line does there.
|
|
376
|
-
return one_shot_exhausted(session, max_iterations) if result.resumable?
|
|
220
|
+
banner_for(session)
|
|
377
221
|
|
|
378
|
-
|
|
379
|
-
|
|
222
|
+
# A session command (/models, !ls) runs as the command, not as a turn.
|
|
223
|
+
return run_one_shot_command(session) if command_registry.command?(@prompt)
|
|
380
224
|
|
|
381
|
-
|
|
382
|
-
# After the idle layer has stopped: nothing writes the session now.
|
|
383
|
-
case exit_action
|
|
384
|
-
when :scratch then nil # deleted below, however the REPL ended
|
|
385
|
-
when :delete then delete_after_exit(session)
|
|
386
|
-
when :discard then discard_after_exit(session)
|
|
387
|
-
when :archive then archive_after_exit(session)
|
|
388
|
-
else keep_after_exit(session)
|
|
389
|
-
end
|
|
390
|
-
# Lines from a pipe (`chi -p X </dev/null`): a failed turn fails the run.
|
|
391
|
-
:turn_failed if @piped_turn_failed
|
|
225
|
+
run_one_shot_turn(session)
|
|
392
226
|
ensure
|
|
393
227
|
# However it ends (the early returns too; the Engine was built in
|
|
394
228
|
# #initialize): the anytime commands finish, the plugins' services
|
|
@@ -397,18 +231,64 @@ module Samagotchi
|
|
|
397
231
|
# below anyway; the others stay).
|
|
398
232
|
clear_pending_card
|
|
399
233
|
@engine&.shutdown
|
|
400
|
-
# A scratch session goes on every way out:
|
|
234
|
+
# A scratch session goes on every way out: the turn's end, an error,
|
|
401
235
|
# SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
|
|
402
236
|
# to the sweep.
|
|
403
|
-
delete_scratch(session
|
|
237
|
+
delete_scratch(session) if @scratch && session
|
|
404
238
|
end
|
|
405
239
|
|
|
406
|
-
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def command_registry = @commands.registry
|
|
243
|
+
|
|
244
|
+
# The turn: its answer on stdout, the retry lines and hook notices on
|
|
245
|
+
# stderr (OneShotSink).
|
|
246
|
+
def run_one_shot_turn(session)
|
|
247
|
+
# A /cut TEXT / /queue TEXT prompt runs its text as the turn's prompt,
|
|
248
|
+
# as an idle attached terminal does (ComposerRoute.steer_choice); a
|
|
249
|
+
# bare /cut / /queue (no text) is refused above as a UI command, so
|
|
250
|
+
# only the text here. The images are the text's, not the words'.
|
|
251
|
+
prompt =
|
|
252
|
+
if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
|
|
253
|
+
choice.last
|
|
254
|
+
else
|
|
255
|
+
@prompt
|
|
256
|
+
end
|
|
257
|
+
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
258
|
+
sink = OneShotSink.new
|
|
259
|
+
save_before_first_turn(session)
|
|
260
|
+
attach_mentioned_context(session, prompt)
|
|
261
|
+
max_iterations = IterationLimit.for(no_interrupt: true)
|
|
262
|
+
begin
|
|
263
|
+
result = @engine.run_turn(session, prompt, on_event: sink, max_iterations: max_iterations,
|
|
264
|
+
cancel_controller: nil, images: ImageInput.extract(prompt))
|
|
265
|
+
rescue LLM::ProviderError, ImageStore::Error => e
|
|
266
|
+
return one_shot_failed(session, e)
|
|
267
|
+
rescue Interrupt
|
|
268
|
+
return one_shot_interrupted(session)
|
|
269
|
+
end
|
|
270
|
+
# No answer (the empty-answer retries used up): no text, or the chat
|
|
271
|
+
# loop's placeholder (LLM::ModelResult#empty_answer?).
|
|
272
|
+
if result.empty_answer?
|
|
273
|
+
# Nothing to print: say so on stderr and fail (bin/chi exits 1),
|
|
274
|
+
# so a script that pipes the answer doesn't take the silence for one.
|
|
275
|
+
session.save
|
|
276
|
+
warn EMPTY_ANSWER_ERROR
|
|
277
|
+
sink.flush
|
|
278
|
+
return :empty_answer
|
|
279
|
+
end
|
|
280
|
+
@surface.commit(result.output)
|
|
281
|
+
session.save
|
|
282
|
+
# The after_turn hooks' notices, after the answer.
|
|
283
|
+
sink.flush
|
|
284
|
+
# The limit cut the turn short: what came so far is no answer, so say
|
|
285
|
+
# so and fail (bin/chi exits 1).
|
|
286
|
+
one_shot_exhausted(session, max_iterations) if result.resumable?
|
|
287
|
+
end
|
|
407
288
|
|
|
408
|
-
# The session is over for this process (its exits and
|
|
289
|
+
# The session is over for this process (its exits and #run's ensure):
|
|
409
290
|
# nothing will resolve the open card any more, so its pending_card.json
|
|
410
|
-
# goes
|
|
411
|
-
# mid-turn).
|
|
291
|
+
# goes.
|
|
412
292
|
def clear_pending_card
|
|
413
293
|
return unless @pending_card_handle
|
|
414
294
|
|
|
@@ -416,9 +296,9 @@ module Samagotchi
|
|
|
416
296
|
@pending_card_handle = nil
|
|
417
297
|
end
|
|
418
298
|
|
|
419
|
-
# A
|
|
420
|
-
#
|
|
421
|
-
#
|
|
299
|
+
# A turn that failed (bin/chi exits 1): the error, then how to go on.
|
|
300
|
+
# The Engine saved the failed turn with its prompt, so the session is
|
|
301
|
+
# kept and `chi --resume` takes it up again.
|
|
422
302
|
# @return [Symbol] :turn_failed
|
|
423
303
|
def one_shot_failed(session, error)
|
|
424
304
|
warn "Error: #{@engine.failure_summary(error)}"
|
|
@@ -428,8 +308,8 @@ module Samagotchi
|
|
|
428
308
|
:turn_failed
|
|
429
309
|
end
|
|
430
310
|
|
|
431
|
-
# A
|
|
432
|
-
#
|
|
311
|
+
# A turn the iteration limit cut short: a line on stderr, with how to go
|
|
312
|
+
# on unless the session goes with this run.
|
|
433
313
|
# @return [Symbol] :iteration_limit
|
|
434
314
|
def one_shot_exhausted(session, max_iterations)
|
|
435
315
|
line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
|
|
@@ -447,7 +327,7 @@ module Samagotchi
|
|
|
447
327
|
line = @prompt.strip
|
|
448
328
|
result = begin
|
|
449
329
|
@commands.run(line, confirm: false)
|
|
450
|
-
rescue StandardError => e
|
|
330
|
+
rescue StandardError => e # degrade-ok: the command's error is its output, and the run fails
|
|
451
331
|
SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
|
|
452
332
|
end
|
|
453
333
|
@surface.commit(result.output) unless result.output.to_s.empty?
|
|
@@ -463,10 +343,10 @@ module Samagotchi
|
|
|
463
343
|
:turn_failed
|
|
464
344
|
end
|
|
465
345
|
|
|
466
|
-
# Ctrl-C in
|
|
467
|
-
#
|
|
468
|
-
#
|
|
469
|
-
#
|
|
346
|
+
# Ctrl-C in the turn: the Engine kept the prompt and a cancel note in
|
|
347
|
+
# the session (it saves only a failed turn), so save it here: `chi
|
|
348
|
+
# --resume` takes it up again. A scratch session goes in #run's ensure,
|
|
349
|
+
# so there is nothing to keep.
|
|
470
350
|
# @return [Symbol] :interrupted
|
|
471
351
|
def one_shot_interrupted(session)
|
|
472
352
|
return :interrupted if @scratch
|
|
@@ -476,77 +356,20 @@ module Samagotchi
|
|
|
476
356
|
:interrupted
|
|
477
357
|
end
|
|
478
358
|
|
|
479
|
-
# Delete the scratch session, the last thing the
|
|
480
|
-
|
|
481
|
-
def delete_scratch(session, quiet:)
|
|
359
|
+
# Delete the scratch session, the last thing the run does.
|
|
360
|
+
def delete_scratch(session)
|
|
482
361
|
@owner_lock&.release
|
|
483
362
|
@owner_lock = nil
|
|
484
363
|
SessionManager.delete_session(session.id)
|
|
485
|
-
|
|
486
|
-
rescue StandardError => e
|
|
364
|
+
rescue StandardError => e # degrade-ok: the line says how to delete it by hand
|
|
487
365
|
warn "Scratch session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}"
|
|
488
366
|
end
|
|
489
367
|
|
|
490
|
-
# The session stays: the recap first, then the resume line, last.
|
|
491
|
-
def keep_after_exit(session)
|
|
492
|
-
recap_after_exit
|
|
493
|
-
@surface.commit("\nContinue session: chi --resume #{session.id}")
|
|
494
|
-
end
|
|
495
|
-
|
|
496
|
-
# The recap for coming back: written before the REPL ends (it has to
|
|
497
|
-
# wait, same process), when there is something new to recap. Ctrl-C
|
|
498
|
-
# gives up on it.
|
|
499
|
-
def recap_after_exit
|
|
500
|
-
@engine.write_recap_now(on_start: -> { @surface.commit("writing a recap…") })
|
|
501
|
-
rescue Interrupt
|
|
502
|
-
nil
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
# Nothing happened in the session (SessionManager.discardable?). The
|
|
506
|
-
# REPL saves only after a turn and keeps /model in its Engine, so a
|
|
507
|
-
# session never saved is judged from memory, and one on another model
|
|
508
|
-
# than the default (/model, --model) is kept.
|
|
509
|
-
def discard_on_exit?(session)
|
|
510
|
-
SessionManager.discardable?(session.id, default_model: @default_model_name, model_name: @effective_model_name,
|
|
511
|
-
used_memory_names: @engine.used_memory_names, unsaved: session)
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
# A session left empty: give it up and delete it, one quiet line.
|
|
515
|
-
def discard_after_exit(session)
|
|
516
|
-
@owner_lock&.release
|
|
517
|
-
@owner_lock = nil
|
|
518
|
-
SessionManager.delete_session(session.id)
|
|
519
|
-
@surface.commit("The session was empty, so it is discarded.")
|
|
520
|
-
rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
|
|
521
|
-
@surface.commit("Continue session: chi --resume #{session.id} (the empty session was not discarded: #{e.message})")
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
# /exit --delete: give up the session, then delete it for good.
|
|
525
|
-
def delete_after_exit(session)
|
|
526
|
-
@owner_lock&.release
|
|
527
|
-
@owner_lock = nil
|
|
528
|
-
SessionManager.delete_session(session.id)
|
|
529
|
-
@surface.commit("Deleted session #{session.id}.")
|
|
530
|
-
rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
|
|
531
|
-
@surface.commit("Session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}")
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
# /archive: give up the session, then archive it (hidden from the lists,
|
|
535
|
-
# kept for good). An empty one is discarded instead (#discard_on_exit?).
|
|
536
|
-
def archive_after_exit(session)
|
|
537
|
-
@owner_lock&.release
|
|
538
|
-
@owner_lock = nil
|
|
539
|
-
SessionManager.archive_session(session.id)
|
|
540
|
-
@surface.commit("Archived session #{session.id}. chi sessions list --archived finds it.")
|
|
541
|
-
rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
|
|
542
|
-
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
543
|
-
end
|
|
544
|
-
|
|
545
368
|
# The session's cards.json and pending_card.json, kept as a worker's
|
|
546
|
-
# Bridge keeps them (it owns the session as a worker would): this
|
|
547
|
-
#
|
|
548
|
-
# place above the turns after it, and its own cards and notices are
|
|
549
|
-
# for the next; an open card with actions is on disk, where
|
|
369
|
+
# Bridge keeps them (it owns the session as a worker would): this run's
|
|
370
|
+
# turn goes on the same turn log, so a card an earlier worker showed keeps
|
|
371
|
+
# its place above the turns after it, and its own cards and notices are
|
|
372
|
+
# saved for the next; an open card with actions is on disk, where
|
|
550
373
|
# `chi web`'s session hub reads it to mark the session as waiting.
|
|
551
374
|
def keep_cards(session)
|
|
552
375
|
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
@@ -555,9 +378,7 @@ module Samagotchi
|
|
|
555
378
|
end
|
|
556
379
|
|
|
557
380
|
# A card a worker that died left isn't open: the file goes before the
|
|
558
|
-
# observer takes over, as Bridge#start does.
|
|
559
|
-
# keeps one session, and a second observer would fold every card twice
|
|
560
|
-
# and clear the file at the first turn's end.
|
|
381
|
+
# observer takes over, as Bridge#start does.
|
|
561
382
|
def keep_pending_card(session)
|
|
562
383
|
return if @pending_card_handle
|
|
563
384
|
|
|
@@ -566,20 +387,20 @@ module Samagotchi
|
|
|
566
387
|
@pending_card_handle = @engine.subscribe(observer: pending)
|
|
567
388
|
end
|
|
568
389
|
|
|
569
|
-
# A question the session's last owner saved and died with (a worker's
|
|
570
|
-
#
|
|
571
|
-
#
|
|
390
|
+
# A question the session's last owner saved and died with (a worker's):
|
|
391
|
+
# this run owns the session now, so nobody waits on it. Dropped and
|
|
392
|
+
# saved, as the worker does (Worker#drop_dead_question).
|
|
572
393
|
def drop_dead_question(session)
|
|
573
394
|
id = session.drop_dead_question
|
|
574
395
|
return unless id
|
|
575
396
|
|
|
576
|
-
Log.info(:
|
|
397
|
+
Log.info(:turn, "dead_question_dropped", id: id)
|
|
577
398
|
session.save
|
|
578
399
|
end
|
|
579
400
|
|
|
580
|
-
# Take the session's OwnerLock for this process's lifetime: the
|
|
401
|
+
# Take the session's OwnerLock for this process's lifetime: the run has
|
|
581
402
|
# its own Engine, so no worker may run the session meanwhile.
|
|
582
|
-
# @raise [SessionBusy] when a worker or another
|
|
403
|
+
# @raise [SessionBusy] when a worker or another chi owns it
|
|
583
404
|
def claim_session!(session_id)
|
|
584
405
|
session_dir = Session.session_dir(session_id)
|
|
585
406
|
@owner_lock = OwnerLock.acquire(session_dir, kind: "tui", wait: 1.0)
|
|
@@ -591,428 +412,29 @@ module Samagotchi
|
|
|
591
412
|
"Close it there first."
|
|
592
413
|
end
|
|
593
414
|
|
|
594
|
-
#
|
|
595
|
-
#
|
|
596
|
-
#
|
|
597
|
-
|
|
598
|
-
# Prints a one-line banner so the user sees which session they're in.
|
|
599
|
-
def messages_for(session)
|
|
600
|
-
system_message = { role: "system", content: seed_system_prompt }
|
|
415
|
+
# Which session this is, and a resumed one's recap, before the turn: on
|
|
416
|
+
# stderr, as stdout is the answer's alone (a script or a parent agent
|
|
417
|
+
# reads it).
|
|
418
|
+
def banner_for(session)
|
|
601
419
|
if @resume_session
|
|
602
|
-
messages = ContextNote.with_system_head(session.messages.dup, system_message)
|
|
603
420
|
banner("Resumed session: #{session.id}")
|
|
604
421
|
saved = @engine.saved_recap
|
|
605
422
|
banner(recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at])) if saved
|
|
606
|
-
# The first prompt after a quiet gap has its divider.
|
|
607
|
-
@gaps.seed(session.messages, fallback: saved&.dig(:created_at))
|
|
608
423
|
elsif @scratch
|
|
609
|
-
messages = [system_message]
|
|
610
424
|
banner("Scratch session: nothing is kept, it is deleted when you leave.")
|
|
611
425
|
else
|
|
612
|
-
messages = [system_message]
|
|
613
426
|
banner("Session: #{session.id}")
|
|
614
427
|
end
|
|
615
|
-
messages
|
|
616
428
|
end
|
|
617
429
|
|
|
618
|
-
# A line about the session, not the answer: on stderr in a
|
|
619
|
-
# --non-interactive run, whose stdout is the answer's alone (a script
|
|
620
|
-
# or a parent agent reads it), and before the turn, so it comes first.
|
|
621
430
|
def banner(text)
|
|
622
|
-
return @surface.commit(text) unless @non_interactive
|
|
623
|
-
|
|
624
431
|
$stderr.puts(text)
|
|
625
432
|
$stderr.flush
|
|
626
433
|
end
|
|
627
434
|
|
|
628
|
-
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
629
|
-
# threaded in here (so --prompt / --resume share one code path). The
|
|
630
|
-
# session's conversation is the single working copy: turns go through
|
|
631
|
-
# Engine#run_turn and out-of-turn edits through Engine's messages API.
|
|
632
|
-
# The checkpoint and continue state live in @turn_flow (shared with
|
|
633
|
-
# session workers). The session is persisted at the end of every turn.
|
|
634
|
-
def run_assist_loop(session:, messages:)
|
|
635
|
-
session.messages = messages
|
|
636
|
-
@engine.session = session
|
|
637
|
-
# Steering: lines submitted at the open prompt during a turn
|
|
638
|
-
# (#steer_line), merged by the kernel (#drain_steering).
|
|
639
|
-
@pending_input_queue = PendingInputQueue.new
|
|
640
|
-
open_repl_input
|
|
641
|
-
|
|
642
|
-
loop do
|
|
643
|
-
if @exit_after_turn
|
|
644
|
-
# Ctrl-D or exit came during a turn: the lines sent before it still
|
|
645
|
-
# run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
|
|
646
|
-
input = queued_line
|
|
647
|
-
break if input.nil?
|
|
648
|
-
else
|
|
649
|
-
# Drain any pending ask_user_question first — it has priority over reminders and
|
|
650
|
-
# must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
|
|
651
|
-
drain_pending_question?
|
|
652
|
-
|
|
653
|
-
# A due reminder runs its turn now, with the prompt open (on a
|
|
654
|
-
# terminal): the turn's output commits above it and whatever is typed
|
|
655
|
-
# there stays.
|
|
656
|
-
unless @engine.due_reminder_names.empty?
|
|
657
|
-
run_reminder_turn(session)
|
|
658
|
-
@turn_flow.after_reminder_turn
|
|
659
|
-
next
|
|
660
|
-
end
|
|
661
|
-
input = @prompt
|
|
662
|
-
@prompt = nil if input
|
|
663
|
-
if input.nil?
|
|
664
|
-
input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
|
|
665
|
-
# A reminder fell due: run it at the top, with the prompt still open.
|
|
666
|
-
next if input == :due
|
|
667
|
-
end
|
|
668
|
-
end
|
|
669
|
-
break if input.nil?
|
|
670
|
-
|
|
671
|
-
local = local_command(input)
|
|
672
|
-
if local == :exit
|
|
673
|
-
@delete_on_exit ||= SessionCommands.delete_on_exit?(input)
|
|
674
|
-
break
|
|
675
|
-
end
|
|
676
|
-
if local == :archive
|
|
677
|
-
next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
|
|
678
|
-
|
|
679
|
-
@archive_on_exit = true
|
|
680
|
-
break
|
|
681
|
-
end
|
|
682
|
-
# Not an answer to a continue offer either.
|
|
683
|
-
next detach_note if local == :detach
|
|
684
|
-
|
|
685
|
-
# /stats and /recap run; the offer stays open. So does a session
|
|
686
|
-
# command (one queued during the turn: /model X, !cmd); /continue
|
|
687
|
-
# answers it.
|
|
688
|
-
if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local) && !offer_leaves_open?(input)
|
|
689
|
-
answer_continue_offer(session, input)
|
|
690
|
-
else
|
|
691
|
-
# The ? read left no echo: show what ran.
|
|
692
|
-
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
|
|
693
|
-
run_input_line(session, input)
|
|
694
|
-
end
|
|
695
|
-
end
|
|
696
|
-
|
|
697
|
-
close_repl_input
|
|
698
|
-
@discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
|
|
699
|
-
end
|
|
700
|
-
|
|
701
|
-
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
702
|
-
# Engine#run_turn, rendering via @renderer.
|
|
703
|
-
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
704
|
-
def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [], resumed: false, resume_from: nil)
|
|
705
|
-
cancellation_controller = CancellationController.new
|
|
706
|
-
@active_cancel_controller = cancellation_controller
|
|
707
|
-
@renderer.begin_turn
|
|
708
|
-
# The row shows the turn's model (a -p turn runs before any read).
|
|
709
|
-
refresh_status_row
|
|
710
|
-
# A prompt the user typed brings an archived session back to the lists.
|
|
711
|
-
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
712
|
-
save_before_first_turn(session)
|
|
713
|
-
attach_mentioned_context(session, prompt) if prompt && !continue
|
|
714
|
-
with_steering do
|
|
715
|
-
@engine.run_turn(
|
|
716
|
-
session,
|
|
717
|
-
prompt,
|
|
718
|
-
on_event: @render_event,
|
|
719
|
-
max_iterations: max_iterations,
|
|
720
|
-
cancel_controller: cancellation_controller,
|
|
721
|
-
pending_input: method(:drain_steering),
|
|
722
|
-
# A steering line waits (#steer_line): a running task_wait ends for it.
|
|
723
|
-
input_waiting: -> { !@pending_input_queue.empty? },
|
|
724
|
-
continue: continue,
|
|
725
|
-
images: images,
|
|
726
|
-
resumed: resumed,
|
|
727
|
-
resume_from: resume_from
|
|
728
|
-
)
|
|
729
|
-
end
|
|
730
|
-
rescue Interrupt
|
|
731
|
-
# Engine kept the prompt in the session and emitted :turn_canceled
|
|
732
|
-
# (the renderer's line).
|
|
733
|
-
cancellation_controller&.cancel!(:ctrl_c)
|
|
734
|
-
cancelled_result_from(session.messages, reason: :ctrl_c)
|
|
735
|
-
ensure
|
|
736
|
-
@active_cancel_controller = nil
|
|
737
|
-
@view.finish_thinking_spinner
|
|
738
|
-
# The turn's end is the last activity.
|
|
739
|
-
@gaps.touch
|
|
740
|
-
end
|
|
741
|
-
|
|
742
|
-
# How the REPL leaves its session once the loop ended (#run): :scratch
|
|
743
|
-
# (deleted however it ends), :delete (/exit --delete), :discard (left
|
|
744
|
-
# empty), :archive (/archive) or :keep.
|
|
745
|
-
def exit_action
|
|
746
|
-
if @scratch then :scratch
|
|
747
|
-
elsif @delete_on_exit then :delete
|
|
748
|
-
elsif @discard_on_exit then :discard
|
|
749
|
-
elsif @archive_on_exit then :archive
|
|
750
|
-
else :keep
|
|
751
|
-
end
|
|
752
|
-
end
|
|
753
|
-
|
|
754
|
-
# The toolkit flags are a new session's: a resumed one keeps its own
|
|
755
|
-
# toolkit (as attached mode says it, AttachLauncher).
|
|
756
|
-
def warn_toolkit_flags_ignored(session)
|
|
757
|
-
return if @toolkit_flags.empty?
|
|
758
|
-
|
|
759
|
-
names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
|
|
760
|
-
warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
|
|
761
|
-
end
|
|
762
|
-
private :warn_toolkit_flags_ignored
|
|
763
|
-
|
|
764
|
-
private
|
|
765
|
-
|
|
766
|
-
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
767
|
-
# threaded in here (so --prompt / --resume share one code path). The
|
|
768
|
-
# working session is persisted at the end of every turn.
|
|
769
|
-
def assist_loop(session:, messages:)
|
|
770
|
-
load_persistent_history
|
|
771
|
-
|
|
772
|
-
# queue_default_input self-guards on @resume_session, so calling it
|
|
773
|
-
# unconditionally preserves the original fresh-session prefill behavior.
|
|
774
|
-
# Skip the default input prefill when a user-provided --prompt was used —
|
|
775
|
-
# the explicit prompt means the user is in command and shouldn't see the
|
|
776
|
-
# default input ("Please " etc.) on the next REPL prompt.
|
|
777
|
-
queue_default_input if @prompt.nil?
|
|
778
|
-
|
|
779
|
-
# The idle layer (reminders + optional recap) is Engine-owned; start the
|
|
780
|
-
# shared scheduler for this REPL and stop it on exit. with_activity_hook
|
|
781
|
-
# resets the shared inactivity clock when a prompt opens and on each key.
|
|
782
|
-
with_live_region do
|
|
783
|
-
with_interrupt_arbiter do
|
|
784
|
-
@engine.start_idle
|
|
785
|
-
with_activity_hook do
|
|
786
|
-
run_assist_loop(session: session, messages: messages)
|
|
787
|
-
ensure
|
|
788
|
-
@engine.stop_idle
|
|
789
|
-
end
|
|
790
|
-
end
|
|
791
|
-
end
|
|
792
|
-
end
|
|
793
|
-
|
|
794
|
-
# Ctrl-C while a Reline read owns stdin: Reline's INT trap catches it, so
|
|
795
|
-
# the REPL hears of it through RelineSeam. A running turn is cancelled and
|
|
796
|
-
# the prompt stays as typed; with no turn running it is Reline's Ctrl-C.
|
|
797
|
-
# (With no read open, Ctrl-C stays an Interrupt on the turn's thread.)
|
|
798
|
-
def with_interrupt_arbiter
|
|
799
|
-
return yield unless RelineSeam.supported?
|
|
800
|
-
|
|
801
|
-
RelineSeam.install
|
|
802
|
-
previous = RelineSeam.interrupt_handler
|
|
803
|
-
RelineSeam.interrupt_handler = method(:cancel_turn_from_prompt)
|
|
804
|
-
begin
|
|
805
|
-
yield
|
|
806
|
-
ensure
|
|
807
|
-
RelineSeam.interrupt_handler = previous
|
|
808
|
-
end
|
|
809
|
-
end
|
|
810
|
-
|
|
811
|
-
# @return [Boolean] a running turn was cancelled
|
|
812
|
-
def cancel_turn_from_prompt
|
|
813
|
-
controller = @active_cancel_controller
|
|
814
|
-
return false unless controller
|
|
815
|
-
|
|
816
|
-
controller.cancel!(:ctrl_c)
|
|
817
|
-
true
|
|
818
|
-
end
|
|
819
|
-
|
|
820
|
-
# Draw the REPL on a live region (a Screen, with Reline's prompt in it)
|
|
821
|
-
# when the terminal can show one; plain output otherwise, or on the
|
|
822
|
-
# surface the UI was given.
|
|
823
|
-
def with_live_region
|
|
824
|
-
screen = LiveRegion.open unless @surface_given
|
|
825
|
-
return yield unless screen
|
|
826
|
-
|
|
827
|
-
plain = @surface
|
|
828
|
-
use_surface(screen)
|
|
829
|
-
begin
|
|
830
|
-
yield
|
|
831
|
-
ensure
|
|
832
|
-
# Nothing may draw on the screen once it is closed: the view moves
|
|
833
|
-
# off it, then its ticker ends.
|
|
834
|
-
use_surface(plain)
|
|
835
|
-
@view.stop
|
|
836
|
-
LiveRegion.close(screen)
|
|
837
|
-
end
|
|
838
|
-
end
|
|
839
|
-
|
|
840
|
-
def use_surface(surface)
|
|
841
|
-
@surface = surface
|
|
842
|
-
@view.surface = surface
|
|
843
|
-
@status_row.surface = surface
|
|
844
|
-
@between_turns.surface = surface
|
|
845
|
-
end
|
|
846
|
-
|
|
847
|
-
# An answer at the ? prompt of a continue offer. A valid one closes the
|
|
848
|
-
# offer's choices and leaves one line; an invalid one gets its error.
|
|
849
|
-
# Whether +input+ is a session command that runs past a continue offer,
|
|
850
|
-
# leaving it open: any but /continue (the worker's S2).
|
|
851
|
-
def offer_leaves_open?(input)
|
|
852
|
-
entry = command_registry.lookup(input)
|
|
853
|
-
!entry.nil? && entry.id != :continue
|
|
854
|
-
end
|
|
855
|
-
|
|
856
|
-
def answer_continue_offer(session, input)
|
|
857
|
-
answer = @commands.continue_answer(input, as_parent: as_parent?)
|
|
858
|
-
if answer.decision == :invalid
|
|
859
|
-
# The read left no echo: the line shows above its error.
|
|
860
|
-
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
|
|
861
|
-
else
|
|
862
|
-
sync_continue_slot(false)
|
|
863
|
-
@surface.commit(QuestionSlot.continue_summary(input.to_s.strip.empty? ? "yes" : input.to_s.strip, paint: method(:paint)))
|
|
864
|
-
end
|
|
865
|
-
return show_command_result(answer) unless answer.resume
|
|
866
|
-
|
|
867
|
-
# The run the offer's turn started: the Continue keeps its reasoning.
|
|
868
|
-
resume_from = @turn_flow.resume_from
|
|
869
|
-
@turn_flow.before_continue_turn
|
|
870
|
-
begin
|
|
871
|
-
result = run_engine_turn(session, nil, continue: true, resumed: true, resume_from: resume_from)
|
|
872
|
-
rescue LLM::ProviderError
|
|
873
|
-
# The renderer showed the failure (:turn_failed).
|
|
874
|
-
@surface.commit(turn_end_hint("continue prompt preserved"))
|
|
875
|
-
return
|
|
876
|
-
end
|
|
877
|
-
finish_turn(session, result, continue: true)
|
|
878
|
-
end
|
|
879
|
-
|
|
880
|
-
# A line at the main prompt: a command, or a prompt for a turn.
|
|
881
|
-
def run_input_line(session, input)
|
|
882
|
-
return if input.empty?
|
|
883
|
-
|
|
884
|
-
# /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
|
|
885
|
-
# the prompt both run the text as a normal turn, as their usage says
|
|
886
|
-
# (the route is the text's).
|
|
887
|
-
route = ComposerRoute.call(input, running: false, registry: command_registry)
|
|
888
|
-
return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
|
|
889
|
-
|
|
890
|
-
if route.choice
|
|
891
|
-
input = route.text
|
|
892
|
-
@surface.commit(STEER_CHOICE_IDLE)
|
|
893
|
-
end
|
|
894
|
-
|
|
895
|
-
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
896
|
-
# an anytime command's cards print as it shows them (btw's
|
|
897
|
-
# "thinking…" before the answer). Piped input has nobody to answer a
|
|
898
|
-
# confirm (the next line is already written): a costly /thinking or
|
|
899
|
-
# /reasoning-history applies at once.
|
|
900
|
-
case route.kind
|
|
901
|
-
when :command
|
|
902
|
-
confirm = !piped_input?
|
|
903
|
-
command = if command_registry.anytime?(input)
|
|
904
|
-
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
905
|
-
else
|
|
906
|
-
# A parent's /continue yes is held to guardrails.parent_answers;
|
|
907
|
-
# only a person here (not piped, no agent marker) loosens
|
|
908
|
-
# /parent-answers.
|
|
909
|
-
parent = as_parent?
|
|
910
|
-
@commands.run(input, confirm: confirm, as_parent: parent && TurnFlow.continue_yes?(input), user_ui: !parent)
|
|
911
|
-
end
|
|
912
|
-
if command
|
|
913
|
-
show_command_result(command)
|
|
914
|
-
refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
|
|
915
|
-
refresh_status_row if command.changed.include?(:title)
|
|
916
|
-
# /recap and /title don't save themselves (a worker saves a changed command).
|
|
917
|
-
save_session(session) if command.changed.intersect?(%i[recap title])
|
|
918
|
-
persist_recent_history(input) if command.shell
|
|
919
|
-
return
|
|
920
|
-
end
|
|
921
|
-
when :local then return if show_local_command(input)
|
|
922
|
-
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
923
|
-
when :typo then return @surface.commit(route.reply)
|
|
924
|
-
end
|
|
925
|
-
|
|
926
|
-
@turn_flow.before_prompt_turn
|
|
927
|
-
persist_recent_history(input)
|
|
928
|
-
# Off a live region (no echo seam) the divider comes here; after
|
|
929
|
-
# #gap_above_echo it marks none.
|
|
930
|
-
mark_activity
|
|
931
|
-
begin
|
|
932
|
-
# Engine#run_turn injects due reminders as a tail message, appends
|
|
933
|
-
# the prompt, and renders through @renderer via on_event.
|
|
934
|
-
result = run_engine_turn(session, input, images: ImageInput.extract(input))
|
|
935
|
-
rescue LLM::ProviderError, ImageStore::Error => e
|
|
936
|
-
# Engine closed the turn (:turn_failed), which the renderer showed.
|
|
937
|
-
# An @path image that can't be used fails the turn the same way.
|
|
938
|
-
# The model reads why on its next turn. An image that couldn't be
|
|
939
|
-
# used, or that the host refused, never reached it: no note for that
|
|
940
|
-
# (a first turn refused that way leaves the session empty, as before).
|
|
941
|
-
note = e.is_a?(LLM::ProviderError) && !e.is_a?(LLM::VisionUnsupported)
|
|
942
|
-
# A turn that got somewhere stays (the renderer said so, and that
|
|
943
|
-
# !rollback erases it), as the Engine saved it: no prompt goes back.
|
|
944
|
-
kept = @turn_flow.prompt_turn_failed(error: e, note: note) == :kept
|
|
945
|
-
# The Engine saved the failed turn; the file follows the rollback.
|
|
946
|
-
save_session(session) if note || kept
|
|
947
|
-
if piped_input?
|
|
948
|
-
# Nobody can edit a restored prompt, and Reline would hand it back
|
|
949
|
-
# as the next line: the turn would run again and again. It failed;
|
|
950
|
-
# the exit status says so.
|
|
951
|
-
@piped_turn_failed = true
|
|
952
|
-
elsif !kept
|
|
953
|
-
@surface.commit(turn_end_hint(restore_prompt_for_retry(input)))
|
|
954
|
-
end
|
|
955
|
-
return
|
|
956
|
-
end
|
|
957
|
-
finish_turn(session, result, continue: false)
|
|
958
|
-
end
|
|
959
|
-
|
|
960
|
-
# After a prompt or continue turn: TurnFlow keeps the checkpoint and the
|
|
961
|
-
# continue offer; the REPL saves and tells the user.
|
|
962
|
-
def finish_turn(session, result, continue:)
|
|
963
|
-
if result.canceled?
|
|
964
|
-
outcome = @turn_flow.after_turn(result, continue: continue)
|
|
965
|
-
# A cancelled continue is back where it started, the offer still open.
|
|
966
|
-
return if outcome == :continue_cancelled
|
|
967
|
-
|
|
968
|
-
# Ctrl-C on a fresh turn. The kernel salvaged completed tool calls
|
|
969
|
-
# and the partial assistant reply (marked [interrupted]) into
|
|
970
|
-
# result.conversation, so progress is preserved by default — the
|
|
971
|
-
# user's next message continues from it. !rollback restores the
|
|
972
|
-
# pre-turn checkpoint for an explicit full discard (the renderer's
|
|
973
|
-
# hint under :turn_canceled says so).
|
|
974
|
-
save_session(session) if result.conversation.is_a?(Array)
|
|
975
|
-
return
|
|
976
|
-
end
|
|
977
|
-
|
|
978
|
-
# The REPL keeps the kernel's conversation as-is (no [No response]
|
|
979
|
-
# placeholder), so /continue resumes from the tool results; the
|
|
980
|
-
# answer keeps what the after_turn hooks stored on it.
|
|
981
|
-
session.messages = with_answer_display(result.conversation, session.messages)
|
|
982
|
-
@turn_flow.after_turn(result, continue: continue)
|
|
983
|
-
save_session(session)
|
|
984
|
-
end
|
|
985
|
-
|
|
986
|
-
# The answer's display keys (Engine#store_answer_display put them on the
|
|
987
|
-
# session's last message) carried onto +conversation+'s last message,
|
|
988
|
-
# when that is the same answer; +conversation+ as it is otherwise.
|
|
989
|
-
ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
|
|
990
|
-
private_constant :ANSWER_DISPLAY_KEYS
|
|
991
|
-
|
|
992
|
-
def with_answer_display(conversation, stored)
|
|
993
|
-
answer = Array(conversation).last
|
|
994
|
-
kept = Array(stored).last
|
|
995
|
-
return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
|
|
996
|
-
return conversation unless message_field(kept, :role).to_s == "model" &&
|
|
997
|
-
message_field(kept, :content) == message_field(answer, :content)
|
|
998
|
-
|
|
999
|
-
extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
|
|
1000
|
-
value = message_field(kept, key)
|
|
1001
|
-
found[key] = value unless value.nil?
|
|
1002
|
-
end
|
|
1003
|
-
extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
|
|
1004
|
-
end
|
|
1005
|
-
|
|
1006
|
-
def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
|
|
1007
|
-
|
|
1008
|
-
def save_session(session)
|
|
1009
|
-
@engine.store_model!(session)
|
|
1010
|
-
session.save
|
|
1011
|
-
end
|
|
1012
|
-
|
|
1013
435
|
# A fresh session reaches the disk before its first turn: a tool in that
|
|
1014
436
|
# turn reads it from there (delegate loads its parent to start a child,
|
|
1015
|
-
# "Session not found" otherwise).
|
|
437
|
+
# "Session not found" otherwise).
|
|
1016
438
|
def save_before_first_turn(session)
|
|
1017
439
|
session.save unless Session.exist?(session.id)
|
|
1018
440
|
end
|
|
@@ -1026,695 +448,28 @@ module Samagotchi
|
|
|
1026
448
|
rescue Interrupt
|
|
1027
449
|
nil
|
|
1028
450
|
rescue StandardError => e
|
|
1029
|
-
Log.warn(:context, "
|
|
1030
|
-
end
|
|
1031
|
-
|
|
1032
|
-
# A !cmd shows its own output; everything else is the model> line. A
|
|
1033
|
-
# change that asks first (CacheConfirm) puts its --yes line in the next
|
|
1034
|
-
# input: Enter applies it.
|
|
1035
|
-
def show_command_result(result)
|
|
1036
|
-
if result.shell
|
|
1037
|
-
@surface.commit(result.output)
|
|
1038
|
-
@surface.commit("")
|
|
1039
|
-
elsif !result.output.nil?
|
|
1040
|
-
@surface.commit("\nmodel> #{result.output}")
|
|
1041
|
-
end
|
|
1042
|
-
queue_input_prefill(result.confirm.line) if result.confirm && !piped_input?
|
|
1043
|
-
sync_model_mirrors if result.changed.include?(:model)
|
|
1044
|
-
end
|
|
1045
|
-
|
|
1046
|
-
# The status line and the next session save read these.
|
|
1047
|
-
def sync_model_mirrors
|
|
1048
|
-
@effective_model_name = @engine.effective_model_name
|
|
1049
|
-
@default_model_name = @commands.default_model
|
|
1050
|
-
end
|
|
1051
|
-
|
|
1052
|
-
# Engine's built-once system prompt (the one run_turn sends).
|
|
1053
|
-
def seed_system_prompt = @engine.system_prompt
|
|
1054
|
-
|
|
1055
|
-
def read_input(awaiting_continue:)
|
|
1056
|
-
refresh_status_row
|
|
1057
|
-
|
|
1058
|
-
if awaiting_continue
|
|
1059
|
-
sync_continue_slot(true)
|
|
1060
|
-
input = Reline.readline(paint(QUESTION_PROMPT, 33), true)
|
|
1061
|
-
return nil if input.nil?
|
|
1062
|
-
|
|
1063
|
-
return input.strip
|
|
1064
|
-
end
|
|
1065
|
-
|
|
1066
|
-
read_prompt_line(paint("> ", 92))
|
|
1067
|
-
rescue Interrupt
|
|
1068
|
-
nil
|
|
1069
|
-
end
|
|
1070
|
-
|
|
1071
|
-
# The synthetic turn for due reminders (Engine#run_turn injects them as
|
|
1072
|
-
# a tail message). The hints row says which, while it runs.
|
|
1073
|
-
def run_reminder_turn(session)
|
|
1074
|
-
names = @engine.due_reminder_names
|
|
1075
|
-
@engine.clear_due_reminder_names!
|
|
1076
|
-
# A normal turn may already have injected them (a stale latch): no
|
|
1077
|
-
# empty synthetic turn then (it duplicated output).
|
|
1078
|
-
return unless @engine.reminders_due?
|
|
1079
|
-
|
|
1080
|
-
# A pending continue offer goes, as for a new prompt (the worker's rule).
|
|
1081
|
-
if @turn_flow.before_reminder_turn
|
|
1082
|
-
sync_continue_slot(false)
|
|
1083
|
-
@surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
|
|
1084
|
-
end
|
|
1085
|
-
mark_activity
|
|
1086
|
-
@surface.commit(reminder_line(names))
|
|
1087
|
-
hint = "#{reminder_line(names)} · Ctrl-C cancels it"
|
|
1088
|
-
@surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
|
|
1089
|
-
result = nil
|
|
1090
|
-
begin
|
|
1091
|
-
result = run_engine_turn(session, nil, continue: true)
|
|
1092
|
-
@prompt = nil
|
|
1093
|
-
rescue LLM::ProviderError
|
|
1094
|
-
nil # the renderer showed the failure; back to the prompt
|
|
1095
|
-
ensure
|
|
1096
|
-
@surface.clear_slot(:hints)
|
|
1097
|
-
end
|
|
1098
|
-
canceled = result&.canceled?
|
|
1099
|
-
if result && !canceled
|
|
1100
|
-
session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
|
|
1101
|
-
@engine.store_model!(session)
|
|
1102
|
-
session.save
|
|
1103
|
-
end
|
|
1104
|
-
# The synthetic turn is activity: the next reminder waits a full
|
|
1105
|
-
# interval (without it they fired back to back).
|
|
1106
|
-
@engine.record_activity
|
|
1107
|
-
end
|
|
1108
|
-
|
|
1109
|
-
# The next line from the prompt, or :due when a reminder falls due first.
|
|
1110
|
-
# On a terminal the prompt stays open the whole session (#open_repl_input):
|
|
1111
|
-
# a reminder turn runs with it (and anything typed in it) still there, and
|
|
1112
|
-
# a line submitted meanwhile comes next. Ctrl-C during a turn reaches
|
|
1113
|
-
# #cancel_turn_from_prompt.
|
|
1114
|
-
def poll_input_with_reminder_check(awaiting_continue:)
|
|
1115
|
-
return :due unless @engine.due_reminder_names.empty?
|
|
1116
|
-
|
|
1117
|
-
# Specs and pipes: a plain blocking read.
|
|
1118
|
-
unless @repl_input
|
|
1119
|
-
@between_turns.flush_cards
|
|
1120
|
-
return read_input(awaiting_continue: awaiting_continue)
|
|
1121
|
-
end
|
|
1122
|
-
|
|
1123
|
-
if @idle_status_due
|
|
1124
|
-
@idle_status_due = false
|
|
1125
|
-
refresh_status_row
|
|
1126
|
-
end
|
|
1127
|
-
sync_continue_slot(awaiting_continue)
|
|
1128
|
-
@repl_input.sync_prompt
|
|
1129
|
-
loop do
|
|
1130
|
-
@between_turns.flush_recap
|
|
1131
|
-
@between_turns.flush_cards
|
|
1132
|
-
kind, line = @repl_input.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
|
|
1133
|
-
if kind
|
|
1134
|
-
# What the line does may change the status (/model, a turn).
|
|
1135
|
-
@idle_status_due = true
|
|
1136
|
-
# Ctrl-C at an idle prompt ends the REPL, as Ctrl-D does.
|
|
1137
|
-
return kind == :line ? line : nil
|
|
1138
|
-
end
|
|
1139
|
-
return :due unless @engine.due_reminder_names.empty?
|
|
1140
|
-
end
|
|
1141
|
-
end
|
|
1142
|
-
|
|
1143
|
-
# On a terminal: one read for the whole session, on a LineReader.
|
|
1144
|
-
def open_repl_input
|
|
1145
|
-
return unless STDIN.tty? && $stdin.tty?
|
|
1146
|
-
|
|
1147
|
-
@idle_status_due = false
|
|
1148
|
-
refresh_status_row
|
|
1149
|
-
@repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
|
|
1150
|
-
end
|
|
1151
|
-
|
|
1152
|
-
def close_repl_input
|
|
1153
|
-
@repl_input&.stop
|
|
1154
|
-
@repl_input = nil
|
|
1155
|
-
end
|
|
1156
|
-
|
|
1157
|
-
# The main prompt, or ? when a continue offer waits and no turn runs.
|
|
1158
|
-
def repl_prompt_text
|
|
1159
|
-
return paint("> ", 92) if @active_cancel_controller || !@turn_flow.awaiting_continue?
|
|
1160
|
-
|
|
1161
|
-
paint(QUESTION_PROMPT, 33)
|
|
1162
|
-
end
|
|
1163
|
-
|
|
1164
|
-
# The notes slot shows a continue offer's choices while it waits.
|
|
1165
|
-
def sync_continue_slot(shown)
|
|
1166
|
-
if shown
|
|
1167
|
-
return if @continue_slot
|
|
1168
|
-
|
|
1169
|
-
@continue_slot = true
|
|
1170
|
-
@surface.set_slot(:notes, QuestionSlot.continue_offer(@turn_flow.offer&.dig(:context), paint: method(:paint)))
|
|
1171
|
-
elsif @continue_slot
|
|
1172
|
-
@continue_slot = false
|
|
1173
|
-
@surface.clear_slot(:notes)
|
|
1174
|
-
end
|
|
1175
|
-
end
|
|
1176
|
-
|
|
1177
|
-
# One read on the reader thread (ReplInput): the multiline read with Tab
|
|
1178
|
-
# completion at the main prompt, a plain line for the continue offer and
|
|
1179
|
-
# a question's ? prompt. +prefill+ is typed in first.
|
|
1180
|
-
def read_repl_line(prompt, prefill)
|
|
1181
|
-
queue_input_prefill(prefill) if prefill
|
|
1182
|
-
return RelineSeam.echo_above(method(:gap_above_echo)) { read_prompt_line(prompt) } if prompt == paint("> ", 92)
|
|
1183
|
-
|
|
1184
|
-
read = -> { with_next_input_prefill { Reline.readline(prompt, true) }&.strip }
|
|
1185
|
-
# A question's answer leaves only its summary line.
|
|
1186
|
-
prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
|
|
1187
|
-
end
|
|
1188
|
-
|
|
1189
|
-
# Activity starts now: a gap's divider first when one passed since the
|
|
1190
|
-
# last (none while a turn runs: a steer is no gap; none in a
|
|
1191
|
-
# --non-interactive run, whose stdout is the answer's alone).
|
|
1192
|
-
def mark_activity
|
|
1193
|
-
line = @gaps.divider(quiet: @non_interactive || @engine.turn_running?)
|
|
1194
|
-
@surface.commit(gap_divider(line)) if line
|
|
1195
|
-
end
|
|
1196
|
-
|
|
1197
|
-
# The divider above a typed line's echo (RelineSeam.echo_above, on the
|
|
1198
|
-
# reader thread): a prompt typed at the idle prompt after a quiet gap.
|
|
1199
|
-
# It marks the activity, so #run_input_line marks none.
|
|
1200
|
-
def gap_above_echo(text)
|
|
1201
|
-
return nil if @engine.turn_running? || @turn_flow.awaiting_continue?
|
|
1202
|
-
|
|
1203
|
-
route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
|
|
1204
|
-
return nil unless %i[prompt deliver].include?(route.kind)
|
|
1205
|
-
|
|
1206
|
-
line = @gaps.divider
|
|
1207
|
-
line && gap_divider(line)
|
|
1208
|
-
end
|
|
1209
|
-
|
|
1210
|
-
# Lines come from a pipe or a file (`chi -p X </dev/null`, `echo … | chi`),
|
|
1211
|
-
# not a terminal: no prompt is restored for a retry.
|
|
1212
|
-
def piped_input? = !$stdin.tty?
|
|
1213
|
-
|
|
1214
|
-
# A failed prompt goes back into the input for a retry.
|
|
1215
|
-
# @return [String] what the failed-turn line says about it
|
|
1216
|
-
def restore_prompt_for_retry(input)
|
|
1217
|
-
unless @repl_input
|
|
1218
|
-
queue_input_prefill(input)
|
|
1219
|
-
return "prompt restored for retry"
|
|
1220
|
-
end
|
|
1221
|
-
@repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
|
|
1222
|
-
end
|
|
1223
|
-
|
|
1224
|
-
# The id of the terminal's own command +input+ is (:exit, :archive,
|
|
1225
|
-
# :detach, :stats, :recap; SessionCommands registers them), or nil.
|
|
1226
|
-
def local_command(input) = command_registry.lookup_local(input)&.id
|
|
1227
|
-
|
|
1228
|
-
# /stats and /recap, which show and change nothing (at the prompt, a
|
|
1229
|
-
# continue offer's ? prompt, or during a turn).
|
|
1230
|
-
# @return [Boolean] whether +input+ was one
|
|
1231
|
-
def show_local_command(input)
|
|
1232
|
-
case local_command(input)
|
|
1233
|
-
when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
|
|
1234
|
-
when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
|
|
1235
|
-
when :unqueue then @surface.commit(REPL_UNQUEUE_NOTE)
|
|
1236
|
-
when :share, :participants then @surface.commit(REPL_SHARE_NOTE)
|
|
1237
|
-
else return false
|
|
1238
|
-
end
|
|
1239
|
-
true
|
|
1240
|
-
end
|
|
1241
|
-
|
|
1242
|
-
# /recap: the saved recap, and a new one asked for at once when the
|
|
1243
|
-
# chat moved on (it prints when it arrives, BetweenTurns#flush_recap).
|
|
1244
|
-
def handle_recap_command
|
|
1245
|
-
recap = @engine.recap
|
|
1246
|
-
return recap_command_text(enabled: false, session_off: @engine.recap_override == false) unless recap
|
|
1247
|
-
|
|
1248
|
-
saved = @engine.saved_recap
|
|
1249
|
-
request = @engine.turn_running? ? :busy : @engine.request_recap
|
|
1250
|
-
recap_command_text(enabled: true, saved: saved, request: request, min_user_turns: recap.min_user_turns)
|
|
1251
|
-
end
|
|
1252
|
-
|
|
1253
|
-
# A resumed session doesn't get the default input either.
|
|
1254
|
-
def default_input_wanted? = !@resume_session && !@no_default_input
|
|
1255
|
-
|
|
1256
|
-
def clone_messages(messages)
|
|
1257
|
-
Array(messages).map(&:dup)
|
|
1258
|
-
end
|
|
1259
|
-
|
|
1260
|
-
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
1261
|
-
# merges at the next iteration boundary (Kernel), and one that comes after
|
|
1262
|
-
# the last runs as the next turn. Reminder turns too.
|
|
1263
|
-
#
|
|
1264
|
-
# A command that waits for the turn's end (/model X, !cmd) and every line
|
|
1265
|
-
# after it wait in @after_turn, in the order typed (D1), with the image
|
|
1266
|
-
# lines (which never steer) and /queue's: once the turn ends they come
|
|
1267
|
-
# back with the steering it didn't merge, in the order sent (each line
|
|
1268
|
-
# the turn takes gets the next @input_seq), ahead of anything sent after
|
|
1269
|
-
# Ctrl-C.
|
|
1270
|
-
def with_steering(&)
|
|
1271
|
-
return yield unless @repl_input
|
|
1272
|
-
|
|
1273
|
-
@after_turn = []
|
|
1274
|
-
@command_waits = false
|
|
1275
|
-
@input_seq = 0
|
|
1276
|
-
@repl_input.during_turn(method(:steer_line), leftovers: -> { leftover_lines }, &)
|
|
1277
|
-
end
|
|
1278
|
-
|
|
1279
|
-
# The turn's leftovers in send order: steering it didn't merge and the
|
|
1280
|
-
# lines that waited for its end.
|
|
1281
|
-
def leftover_lines
|
|
1282
|
-
(@pending_input_queue.drain_entries + lines_after_turn).sort_by { |seq, _| seq || 0 }.map(&:last)
|
|
1283
|
-
end
|
|
1284
|
-
|
|
1285
|
-
# The next line's place in send order (#leftover_lines).
|
|
1286
|
-
def next_input_seq
|
|
1287
|
-
@input_seq = (@input_seq || 0) + 1
|
|
1288
|
-
end
|
|
1289
|
-
|
|
1290
|
-
# The [seq, line] entries that waited for the turn's end, taken from
|
|
1291
|
-
# @after_turn. After a canceled or failed turn (Ctrl-C raises through
|
|
1292
|
-
# here; a failure too) a !cmd among them is dropped (D4): running it
|
|
1293
|
-
# would close the !rollback the turn left.
|
|
1294
|
-
def lines_after_turn
|
|
1295
|
-
entries = @after_turn.slice!(0..)
|
|
1296
|
-
ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
|
|
1297
|
-
elsif $! then "failed"
|
|
1298
|
-
end
|
|
1299
|
-
return entries unless ended
|
|
1300
|
-
|
|
1301
|
-
entries.reject do |_seq, line|
|
|
1302
|
-
next false unless command_registry.lookup(line)&.id == :shell
|
|
1303
|
-
|
|
1304
|
-
@surface.commit("turn #{ended}: #{line.strip} not run; send it again")
|
|
1305
|
-
true
|
|
1306
|
-
end
|
|
1307
|
-
end
|
|
1308
|
-
|
|
1309
|
-
# On the reader thread, from ReplInput: takes a line for the running turn.
|
|
1310
|
-
# A line sent after Ctrl-C waits for the next turn (the kernel would not
|
|
1311
|
-
# merge it into the cancelled one). Ctrl-D or exit ends the REPL after
|
|
1312
|
-
# the turn. /stats and /recap run now, an anytime command (/help, a
|
|
1313
|
-
# plugin's /btw, /model alone) starts now on its own thread; one that
|
|
1314
|
-
# runs after the turn (/model X, !cmd) waits in the queue, in arrival
|
|
1315
|
-
# order with the other lines that wait; others go back in the prompt.
|
|
1316
|
-
#
|
|
1317
|
-
# A plain line goes in at the running turn's next step boundary and
|
|
1318
|
-
# never cuts; /cut TEXT asks for a cut, /queue TEXT waits for the turn
|
|
1319
|
-
# to end and runs as a turn of its own.
|
|
1320
|
-
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1321
|
-
def steer_line(line)
|
|
1322
|
-
route = ComposerRoute.call(line, running: true, registry: command_registry) unless line.nil?
|
|
1323
|
-
local = route&.local
|
|
1324
|
-
return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
|
|
1325
|
-
return archive_after_turn if local == :archive
|
|
1326
|
-
return detach_note if local == :detach
|
|
1327
|
-
return false if @active_cancel_controller&.cancelled?
|
|
1328
|
-
|
|
1329
|
-
case route.kind
|
|
1330
|
-
# Before every command check (ComposerRoute): /cut and /queue are the
|
|
1331
|
-
# UIs' own words for a delivery (listed for Tab and /help, never run).
|
|
1332
|
-
when :usage
|
|
1333
|
-
@surface.commit(STEER_CHOICE_USAGE)
|
|
1334
|
-
return true
|
|
1335
|
-
when :deliver then return submit_steer_choice(route.delivery, route.text)
|
|
1336
|
-
when :command
|
|
1337
|
-
policy = command_registry.mid_turn(line)
|
|
1338
|
-
return start_anytime_command(line) if policy == :anytime
|
|
1339
|
-
return command_queued(line) if policy == :queue
|
|
1340
|
-
end
|
|
1341
|
-
# A command, never steering text.
|
|
1342
|
-
return command_during_turn(line) if %i[local command].include?(route.kind)
|
|
1343
|
-
return true if route.kind == :empty
|
|
1344
|
-
|
|
1345
|
-
# A typo of a command (/modle) is not steering text: say so, take it.
|
|
1346
|
-
if route.kind == :typo
|
|
1347
|
-
@surface.commit(route.reply)
|
|
1348
|
-
return true
|
|
1349
|
-
end
|
|
1350
|
-
# Steering merges text only: a line with images runs as the next turn.
|
|
1351
|
-
return image_line_waits(line) if ImageInput.extract(line).any?
|
|
1352
|
-
# Behind a command that waits for the turn's end, a line waits too.
|
|
1353
|
-
return wait_for_turn_end(line) if @command_waits
|
|
1354
|
-
|
|
1355
|
-
@pending_input_queue.push(route.text, seq: next_input_seq)
|
|
1356
|
-
@surface.commit(NEXT_STEP_NOTE)
|
|
1357
|
-
true
|
|
1358
|
-
end
|
|
1359
|
-
|
|
1360
|
-
# /cut TEXT and /queue TEXT during a turn, which the REPL would
|
|
1361
|
-
# otherwise read as prompts (SessionCommands registers them for Tab and
|
|
1362
|
-
# help only). A cut asks the engine to cut the generation only thinking
|
|
1363
|
-
# too long; a queue waits for the turn's end (the REPL's own after-turn
|
|
1364
|
-
# list). A line with images never steers (as a plain one doesn't).
|
|
1365
|
-
# @param delivery [String] Delivery::CUT or Delivery::QUEUE (ComposerRoute)
|
|
1366
|
-
# @return [Boolean] whether the turn took it
|
|
1367
|
-
def submit_steer_choice(delivery, text)
|
|
1368
|
-
return image_line_waits(text) if ImageInput.extract(text).any?
|
|
1369
|
-
|
|
1370
|
-
# A /queue waits for the turn's end; a /cut behind a command that
|
|
1371
|
-
# already waits there goes after it too.
|
|
1372
|
-
if Delivery.queue?(delivery) || @command_waits
|
|
1373
|
-
@surface.commit(COMMAND_QUEUED)
|
|
1374
|
-
return wait_for_turn_end(text)
|
|
1375
|
-
end
|
|
1376
|
-
|
|
1377
|
-
epoch = @engine.input_epoch
|
|
1378
|
-
@pending_input_queue.push(text, seq: next_input_seq)
|
|
1379
|
-
# With no session running the cut is :off; the text still waits for
|
|
1380
|
-
# the running turn's next step.
|
|
1381
|
-
@surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
|
|
1382
|
-
true
|
|
1383
|
-
end
|
|
1384
|
-
|
|
1385
|
-
def image_line_waits(line)
|
|
1386
|
-
@surface.commit(IMAGE_LINE_WAITS)
|
|
1387
|
-
wait_for_turn_end(line)
|
|
1388
|
-
end
|
|
1389
|
-
|
|
1390
|
-
# +line+ runs once the turn has ended, in the order typed (#with_steering).
|
|
1391
|
-
# @return [true] the turn took it
|
|
1392
|
-
def wait_for_turn_end(line)
|
|
1393
|
-
@after_turn << [next_input_seq, line]
|
|
1394
|
-
true
|
|
1395
|
-
end
|
|
1396
|
-
|
|
1397
|
-
def detach_note
|
|
1398
|
-
@surface.commit(REPL_DETACH_NOTE)
|
|
1399
|
-
true
|
|
1400
|
-
end
|
|
1401
|
-
|
|
1402
|
-
# /archive during a turn (D2): the REPL leaves and archives once the
|
|
1403
|
-
# turn and the lines queued behind it (S5) ran, as /exit does.
|
|
1404
|
-
# @return [true]
|
|
1405
|
-
def archive_after_turn
|
|
1406
|
-
return @surface.commit(SCRATCH_ARCHIVE_REFUSED).then { true } if @scratch
|
|
1407
|
-
|
|
1408
|
-
@exit_after_turn = true
|
|
1409
|
-
@archive_on_exit = true
|
|
1410
|
-
@surface.commit("(archives after this turn; Ctrl-C cancels it)")
|
|
1411
|
-
true
|
|
1412
|
-
end
|
|
1413
|
-
|
|
1414
|
-
def exit_after_turn(delete: false)
|
|
1415
|
-
@exit_after_turn = true
|
|
1416
|
-
@delete_on_exit = true if delete
|
|
1417
|
-
@surface.commit(delete ? "(exits after this turn and deletes the session; Ctrl-C cancels the turn)" : "(exits after this turn; Ctrl-C cancels it)")
|
|
1418
|
-
true
|
|
1419
|
-
end
|
|
1420
|
-
|
|
1421
|
-
# A line already queued (none waits for input), or nil.
|
|
1422
|
-
def queued_line
|
|
1423
|
-
kind, line = @repl_input&.pop(timeout: 0)
|
|
1424
|
-
kind == :line ? line : nil
|
|
1425
|
-
end
|
|
1426
|
-
|
|
1427
|
-
# @return [true, :back]
|
|
1428
|
-
def command_during_turn(line)
|
|
1429
|
-
return true if show_local_command(line)
|
|
1430
|
-
|
|
1431
|
-
@surface.commit(command_registry.lookup(line)&.id == :rollback ? ROLLBACK_BUSY : COMMAND_BUSY)
|
|
1432
|
-
:back
|
|
1433
|
-
end
|
|
1434
|
-
|
|
1435
|
-
# @return [true] it waits for the turn's end (#wait_for_turn_end)
|
|
1436
|
-
def command_queued(line)
|
|
1437
|
-
@surface.commit(COMMAND_QUEUED)
|
|
1438
|
-
@command_waits = true
|
|
1439
|
-
wait_for_turn_end(line)
|
|
1440
|
-
end
|
|
1441
|
-
|
|
1442
|
-
# D8: an anytime command runs on its own thread while the turn goes on
|
|
1443
|
-
# (it reads copies, and shows things through its ctx). What it prints
|
|
1444
|
-
# while the turn runs goes above the live region now, its cards as it
|
|
1445
|
-
# shows them (BetweenTurns#observe); once the turn has ended it waits for
|
|
1446
|
-
# the prompt's flush.
|
|
1447
|
-
# @return [true]
|
|
1448
|
-
def start_anytime_command(line)
|
|
1449
|
-
@engine.spawn_anytime do
|
|
1450
|
-
output = @engine.running_anytime { @commands.run(line) }&.output
|
|
1451
|
-
@between_turns.show_or_keep({ type: :command_output, text: "\nmodel> #{output}" }) unless output.nil?
|
|
1452
|
-
rescue StandardError => e
|
|
1453
|
-
@between_turns.keep({ type: :command_output, text: "\nmodel> #{line.split.first}: #{e.message}" })
|
|
1454
|
-
end
|
|
1455
|
-
true
|
|
1456
|
-
end
|
|
1457
|
-
|
|
1458
|
-
# The kernel's drain at an iteration boundary: queued lines, sent as
|
|
1459
|
-
# typed and saved in the history.
|
|
1460
|
-
def drain_steering
|
|
1461
|
-
@pending_input_queue.drain.map do |line|
|
|
1462
|
-
persist_recent_history(line)
|
|
1463
|
-
line
|
|
1464
|
-
end
|
|
1465
|
-
end
|
|
1466
|
-
|
|
1467
|
-
# The REPL's on_event sink for Engine#run_turn: render one event, after
|
|
1468
|
-
# taking what the status row shows from it (as AttachedLoop does from the
|
|
1469
|
-
# Bridge's). Engine swallows on_event errors to protect the turn, so log
|
|
1470
|
-
# ours instead.
|
|
1471
|
-
def handle_stream_event(event)
|
|
1472
|
-
@status_row.take_event(event)
|
|
1473
|
-
@renderer.call(event)
|
|
1474
|
-
rescue StandardError => e
|
|
1475
|
-
Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
|
|
1476
|
-
end
|
|
1477
|
-
|
|
1478
|
-
def cancelled_result_from(messages, reason:)
|
|
1479
|
-
LLM::ModelResult.new(text: "", conversation: clone_messages(messages), canceled: true, cancellation_reason: reason)
|
|
1480
|
-
end
|
|
1481
|
-
|
|
1482
|
-
# The status row under the prompt, from the Engine's state (what a
|
|
1483
|
-
# worker hands an attached TUI when it joins): drawn when it changed.
|
|
1484
|
-
def refresh_status_row(clear_unknown_context: false)
|
|
1485
|
-
state = @engine.session_state_snapshot.merge(model_name: @effective_model_name)
|
|
1486
|
-
@status_row.take_state(state, default_model: @default_model_name)
|
|
1487
|
-
@status_row.update(context: nil) if clear_unknown_context && state.key?(:context_status) && state[:context_status].nil?
|
|
1488
|
-
refresh_children_status
|
|
1489
|
-
end
|
|
1490
|
-
|
|
1491
|
-
# The status row's children segment: this session's delegates, read
|
|
1492
|
-
# from their files (ChildrenStatus.counts) wherever the row is drawn
|
|
1493
|
-
# again. Not read with status.line off; a failed read keeps the row.
|
|
1494
|
-
def refresh_children_status
|
|
1495
|
-
id = @engine.session&.id
|
|
1496
|
-
return unless id && @status_row.enabled?
|
|
1497
|
-
|
|
1498
|
-
@status_row.update(children: ChildrenStatus.counts(id, state_dir: @engine.peer_state_dir || Session.default_state_dir))
|
|
1499
|
-
rescue StandardError => e
|
|
1500
|
-
Log.debug(:repl, "children_counts_failed", error: e.class.name)
|
|
1501
|
-
end
|
|
1502
|
-
|
|
1503
|
-
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1504
|
-
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
1505
|
-
# single seam the Engine-owned idle recap detector reads, so the idle clock
|
|
1506
|
-
# is identical for the REPL and any other Engine-backed UI. Chains onto any
|
|
1507
|
-
# previously-installed hook (e.g. the input prefill hook).
|
|
1508
|
-
def with_activity_hook
|
|
1509
|
-
previous_hook = Reline.pre_input_hook
|
|
1510
|
-
previous_key_handler = RelineSeam.key_handler
|
|
1511
|
-
Reline.pre_input_hook = proc do
|
|
1512
|
-
@engine.record_activity
|
|
1513
|
-
previous_hook.call if previous_hook
|
|
1514
|
-
end
|
|
1515
|
-
# Typing is activity too: a reminder waits while you type.
|
|
1516
|
-
RelineSeam.key_handler = -> { @engine.record_activity }
|
|
1517
|
-
yield
|
|
1518
|
-
ensure
|
|
1519
|
-
Reline.pre_input_hook = previous_hook
|
|
1520
|
-
RelineSeam.key_handler = previous_key_handler
|
|
1521
|
-
end
|
|
1522
|
-
|
|
1523
|
-
# ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
|
|
1524
|
-
|
|
1525
|
-
# Non-blocking observer: the turn thread emits :question_requested; we stash
|
|
1526
|
-
# it so the REPL thread (the only one that may touch Reline) can drain it
|
|
1527
|
-
# at the top of run_assist_loop without racing the completion.
|
|
1528
|
-
def handle_question_event(event)
|
|
1529
|
-
return unless event.is_a?(Hash)
|
|
1530
|
-
|
|
1531
|
-
type = event[:type] || event["type"]
|
|
1532
|
-
return unless type.to_s == "question_requested"
|
|
1533
|
-
|
|
1534
|
-
pq = event[:pending_question] || event["pending_question"]
|
|
1535
|
-
return unless pq
|
|
1536
|
-
|
|
1537
|
-
@pending_question_event = pq
|
|
1538
|
-
rescue StandardError
|
|
1539
|
-
nil
|
|
1540
|
-
end
|
|
1541
|
-
|
|
1542
|
-
# Called from REPL thread (run_assist_loop top) — renders widget, blocks
|
|
1543
|
-
# until user selects, then answers via Engine#answer_question which wakes
|
|
1544
|
-
# the parked turn thread.
|
|
1545
|
-
def drain_pending_question?
|
|
1546
|
-
pq = @pending_question_event
|
|
1547
|
-
# Also check Engine's persisted pending (covers resume)
|
|
1548
|
-
pq ||= @engine.pending_question&.to_h
|
|
1549
|
-
return false unless pq
|
|
1550
|
-
|
|
1551
|
-
# If the pending no longer matches the engine's current pending (stale
|
|
1552
|
-
# event after sync handler already answered and cleared), discard.
|
|
1553
|
-
current = @engine.pending_question
|
|
1554
|
-
if current && (pq[:id] || pq["id"]).to_s != current.id.to_s
|
|
1555
|
-
@pending_question_event = nil
|
|
1556
|
-
return false
|
|
1557
|
-
end
|
|
1558
|
-
if current.nil? && @pending_question_event
|
|
1559
|
-
# Stale stash where engine already cleared (sync path completed)
|
|
1560
|
-
@pending_question_event = nil
|
|
1561
|
-
return false
|
|
1562
|
-
end
|
|
1563
|
-
|
|
1564
|
-
@pending_question_event = nil
|
|
1565
|
-
result = render_question_widget(pq)
|
|
1566
|
-
return false unless result
|
|
1567
|
-
|
|
1568
|
-
# result is already answered via Engine#answer_question in render_question_widget
|
|
1569
|
-
true
|
|
1570
|
-
rescue StandardError => e
|
|
1571
|
-
Log.warn(:repl, "question_drain_failed", echo: "[ask_user_question] drain failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
1572
|
-
false
|
|
451
|
+
Log.warn(:context, "candidates_one_shot_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1573
452
|
end
|
|
1574
453
|
|
|
1575
|
-
# The
|
|
1576
|
-
#
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
prompt = QuestionPrompt.new(pending)
|
|
1580
|
-
|
|
1581
|
-
# The activity row goes while the question waits.
|
|
1582
|
-
@view.finish_thinking_spinner
|
|
1583
|
-
return dismiss_unaskable_question(prompt) if @non_interactive
|
|
1584
|
-
|
|
1585
|
-
# An edit's diff goes above the slot, into the scrollback.
|
|
1586
|
-
preview = prompt.preview_lines(paint: method(:paint))
|
|
1587
|
-
@surface.commit(preview.join("\n")) unless preview.empty?
|
|
1588
|
-
choices = prompt.slot(paint: method(:paint))
|
|
1589
|
-
|
|
1590
|
-
question_prompt = paint(QUESTION_PROMPT, 33)
|
|
1591
|
-
unless @repl_input
|
|
1592
|
-
@surface.set_slot(:notes, choices)
|
|
1593
|
-
return answer_question_widget(prompt) { read_choice_line(question_prompt) }
|
|
1594
|
-
end
|
|
454
|
+
# The toolkit flags are a new session's: a resumed one keeps its own
|
|
455
|
+
# toolkit (as attached mode says it, AttachLauncher).
|
|
456
|
+
def warn_toolkit_flags_ignored(session)
|
|
457
|
+
return if @toolkit_flags.empty?
|
|
1595
458
|
|
|
1596
|
-
|
|
1597
|
-
#
|
|
1598
|
-
# never one typed before the question came. The choices show once the
|
|
1599
|
-
# typed text is out of the prompt.
|
|
1600
|
-
@repl_input.ask(question_prompt) do |answers|
|
|
1601
|
-
@surface.set_slot(:notes, choices)
|
|
1602
|
-
answer_question_widget(prompt) { take_open_prompt_answer(answers) }
|
|
1603
|
-
end
|
|
1604
|
-
ensure
|
|
1605
|
-
@surface.clear_slot(:notes)
|
|
459
|
+
names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
|
|
460
|
+
warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
|
|
1606
461
|
end
|
|
1607
462
|
|
|
1608
|
-
#
|
|
1609
|
-
# question
|
|
1610
|
-
#
|
|
1611
|
-
#
|
|
1612
|
-
#
|
|
1613
|
-
#
|
|
1614
|
-
# dismissed it.
|
|
463
|
+
# No one is here to ask (as for an approval or a hook's question): the
|
|
464
|
+
# question is dismissed at once, as an empty answer would be, and the
|
|
465
|
+
# model finishes its reply. Never a read of stdin: a pipe its writer
|
|
466
|
+
# keeps open would block it forever. Its one line goes to stderr. The
|
|
467
|
+
# model is told no one could answer (QuestionDesk::UNANSWERABLE_NOTE),
|
|
468
|
+
# not that the user dismissed it.
|
|
1615
469
|
def dismiss_unaskable_question(prompt)
|
|
1616
|
-
@engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON)
|
|
470
|
+
Degrade.call(:turn, "question_dismiss_failed") { @engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) }
|
|
1617
471
|
banner(prompt.summary(QuestionPrompt::UNANSWERABLE_TEXT, paint: method(:paint)))
|
|
1618
472
|
false
|
|
1619
473
|
end
|
|
1620
|
-
|
|
1621
|
-
# Loop until a valid selection or a cancel. The block reads one answer:
|
|
1622
|
-
# a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
|
|
1623
|
-
# was cancelled).
|
|
1624
|
-
def answer_question_widget(prompt)
|
|
1625
|
-
loop do
|
|
1626
|
-
@choice_echoed = false
|
|
1627
|
-
raw = begin
|
|
1628
|
-
yield
|
|
1629
|
-
rescue Interrupt
|
|
1630
|
-
nil
|
|
1631
|
-
end
|
|
1632
|
-
raw = raw.to_s.strip unless raw.nil? || raw == :canceled
|
|
1633
|
-
if raw.nil? || raw == :canceled || raw.empty?
|
|
1634
|
-
# Empty, EOF / Ctrl-D / Ctrl-C -> cancel (an approval: denied)
|
|
1635
|
-
@engine.cancel_question("user") rescue nil
|
|
1636
|
-
outcome = if raw == :canceled then "(turn cancelled)"
|
|
1637
|
-
elsif prompt.approval? then "(denied)"
|
|
1638
|
-
else "(cancelled)"
|
|
1639
|
-
end
|
|
1640
|
-
close_question_widget(prompt, outcome)
|
|
1641
|
-
return false
|
|
1642
|
-
end
|
|
1643
|
-
|
|
1644
|
-
answer = prompt.parse(raw)
|
|
1645
|
-
@surface.commit(answer.note) if answer.note
|
|
1646
|
-
unless answer.ok?
|
|
1647
|
-
# The read left no echo: the line shows above its error.
|
|
1648
|
-
echo_choice(raw)
|
|
1649
|
-
@surface.commit(answer.error)
|
|
1650
|
-
next
|
|
1651
|
-
end
|
|
1652
|
-
|
|
1653
|
-
begin
|
|
1654
|
-
parent = as_parent? ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
1655
|
-
@engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
1656
|
-
close_question_widget(prompt, prompt.answer_text(answer))
|
|
1657
|
-
return true
|
|
1658
|
-
rescue QuestionDesk::Refused => e
|
|
1659
|
-
# A parent's pick its strategy doesn't let through
|
|
1660
|
-
# (Guardrails::ParentPolicy): the question stays open for a deny
|
|
1661
|
-
# (or the end of the input).
|
|
1662
|
-
echo_choice(raw)
|
|
1663
|
-
@surface.commit(e.detail(typed: true))
|
|
1664
|
-
next
|
|
1665
|
-
rescue ArgumentError => e
|
|
1666
|
-
@surface.commit("Invalid: #{e.message}. Try again.")
|
|
1667
|
-
next
|
|
1668
|
-
rescue StandardError => e
|
|
1669
|
-
close_question_widget(prompt, "(error: #{e.message})")
|
|
1670
|
-
return false
|
|
1671
|
-
end
|
|
1672
|
-
end
|
|
1673
|
-
end
|
|
1674
|
-
|
|
1675
|
-
# Whether the answers typed here are a parent agent's (piped stdin, an
|
|
1676
|
-
# agent marker): QuestionDesk holds them to Guardrails::ParentPolicy.
|
|
1677
|
-
def as_parent?
|
|
1678
|
-
Guardrails::ParentApprovals.parent_process?
|
|
1679
|
-
end
|
|
1680
|
-
|
|
1681
|
-
def close_question_widget(prompt, outcome)
|
|
1682
|
-
@surface.clear_slot(:notes)
|
|
1683
|
-
@surface.commit(prompt.summary(outcome, paint: method(:paint)))
|
|
1684
|
-
end
|
|
1685
|
-
|
|
1686
|
-
# A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
|
|
1687
|
-
def read_choice_line(question_prompt)
|
|
1688
|
-
# Off a terminal the ? prompt can't be taken back: at the input's end
|
|
1689
|
-
# it would stay bare above the summary. eof? waits as gets would.
|
|
1690
|
-
return nil if !$stdin.tty? && $stdin.eof?
|
|
1691
|
-
|
|
1692
|
-
@surface.set_slot(:editor, [question_prompt])
|
|
1693
|
-
line = $stdin.gets
|
|
1694
|
-
# A pipe doesn't echo: the line completes the ? prompt here, once,
|
|
1695
|
-
# and the widget doesn't echo it again (#echo_choice).
|
|
1696
|
-
unless $stdin.tty?
|
|
1697
|
-
@surface.commit(line.to_s.chomp)
|
|
1698
|
-
@choice_echoed = true
|
|
1699
|
-
end
|
|
1700
|
-
line
|
|
1701
|
-
end
|
|
1702
|
-
|
|
1703
|
-
# The answer line above its error, unless its read echoed it already.
|
|
1704
|
-
def echo_choice(raw)
|
|
1705
|
-
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
|
|
1706
|
-
end
|
|
1707
|
-
|
|
1708
|
-
# The next line submitted at the open prompt (?), nil for Ctrl-D
|
|
1709
|
-
# or a Ctrl-C with no turn running, or :canceled when Ctrl-C cancels the
|
|
1710
|
-
# running turn first (the read goes on; the question closes).
|
|
1711
|
-
def take_open_prompt_answer(answers)
|
|
1712
|
-
controller = @active_cancel_controller
|
|
1713
|
-
loop do
|
|
1714
|
-
kind, line = answers.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
|
|
1715
|
-
return kind == :line ? line : nil if kind
|
|
1716
|
-
return :canceled if controller&.cancelled?
|
|
1717
|
-
end
|
|
1718
|
-
end
|
|
1719
474
|
end
|
|
1720
475
|
end
|