samagotchi 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "formatting"
|
|
5
|
+
require_relative "../output_formatter"
|
|
6
|
+
require_relative "../turn_tally"
|
|
7
|
+
require_relative "thinking_line"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
class TerminalUI
|
|
11
|
+
# EventRenderer's view in attached mode, drawn on a Screen: the
|
|
12
|
+
# thinking feedback is the activity slot, one row right above the prompt
|
|
13
|
+
# (spinner frame, then the running tool or the newest sentence of the
|
|
14
|
+
# model's thinking or text, ThinkingLine),
|
|
15
|
+
# and every finished line is committed output. The local REPL's
|
|
16
|
+
# multi-line spinner redraws in place, which can't share the terminal
|
|
17
|
+
# with an open prompt.
|
|
18
|
+
#
|
|
19
|
+
# The spinner turns with time, not per chunk: while the slot is shown a
|
|
20
|
+
# ticker thread redraws it every TICK_INTERVAL, so a turn that gets no
|
|
21
|
+
# chunks (a queued remote request, a long prompt eval) still looks alive,
|
|
22
|
+
# and after WAIT_NOTICE_AFTER seconds with no chunk it says how long it
|
|
23
|
+
# has been waiting for the first token.
|
|
24
|
+
#
|
|
25
|
+
# From a turn's TurnTally::MIN_CALLS-th tool call, a second, dim row under
|
|
26
|
+
# it tallies the turn's calls (TurnTally).
|
|
27
|
+
class AttachedView
|
|
28
|
+
include Formatting
|
|
29
|
+
|
|
30
|
+
FRAMES = ["|", "/", "-", "\\"].freeze
|
|
31
|
+
# Chunks arrive faster than a status line is worth redrawing.
|
|
32
|
+
MIN_REDRAW_INTERVAL = 0.08
|
|
33
|
+
# Seconds per spinner frame.
|
|
34
|
+
FRAME_INTERVAL = 0.25
|
|
35
|
+
# Seconds between the ticker's redraws (specs stub it to nil: no thread).
|
|
36
|
+
TICK_INTERVAL = FRAME_INTERVAL
|
|
37
|
+
WAIT_NOTICE_AFTER = 2.0
|
|
38
|
+
|
|
39
|
+
attr_reader :context_status
|
|
40
|
+
|
|
41
|
+
# @param screen [Surface] with #columns (a Screen)
|
|
42
|
+
# @param tick_interval [Numeric, nil] seconds between the ticker's
|
|
43
|
+
# redraws; nil: no ticker thread (specs call #tick)
|
|
44
|
+
def initialize(screen, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, tick_interval: TICK_INTERVAL)
|
|
45
|
+
@screen = screen
|
|
46
|
+
@clock = clock
|
|
47
|
+
@tick_interval = tick_interval
|
|
48
|
+
@origin = clock.call
|
|
49
|
+
@lock = Monitor.new
|
|
50
|
+
@ticker = nil
|
|
51
|
+
@tally = TurnTally.new
|
|
52
|
+
@line = ThinkingLine.new(clock: clock)
|
|
53
|
+
# Plugins' init tasks running now (chi.init), id => "bundle: label".
|
|
54
|
+
@init_tasks = {}
|
|
55
|
+
reset_turn_feedback
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def print_line(text)
|
|
59
|
+
@screen.commit(text)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A new turn: its tally starts over too.
|
|
63
|
+
def reset_turn_feedback
|
|
64
|
+
@lock.synchronize do
|
|
65
|
+
reset_feedback_state
|
|
66
|
+
@tally.reset
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param _event [Hash] :generation_started (its context window isn't
|
|
71
|
+
# shown: the status line is one row)
|
|
72
|
+
def generation_feedback_started(_event = {})
|
|
73
|
+
@lock.synchronize do
|
|
74
|
+
@thinking = true
|
|
75
|
+
@line.reset
|
|
76
|
+
@tool = nil
|
|
77
|
+
@waiting_since = @clock.call
|
|
78
|
+
redraw_status
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def generation_feedback_retrying(event)
|
|
83
|
+
@lock.synchronize do
|
|
84
|
+
@retry = "retrying (attempt #{event[:attempt]})"
|
|
85
|
+
# The retry streams from the start: wait for its first token again.
|
|
86
|
+
@waiting_since = @clock.call if @thinking
|
|
87
|
+
redraw_status
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def generation_feedback_chunk(event)
|
|
92
|
+
@lock.synchronize do
|
|
93
|
+
@retry = nil
|
|
94
|
+
@waiting_since = nil
|
|
95
|
+
@line.chunk(event)
|
|
96
|
+
redraw_status(throttle: true)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def tool_call_feedback_started(event)
|
|
101
|
+
@lock.synchronize do
|
|
102
|
+
@retry = nil
|
|
103
|
+
@waiting_since = nil
|
|
104
|
+
@tool = "running #{event[:tool]}"
|
|
105
|
+
@tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
|
|
106
|
+
redraw_status
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def tool_call_feedback_completed(event)
|
|
111
|
+
@lock.synchronize do
|
|
112
|
+
@tally.completed(key: tally_key(event), tool: event[:tool],
|
|
113
|
+
status: event.dig(:activity, :status), params: event.dig(:activity, :params))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def clear_generation_retry
|
|
118
|
+
@lock.synchronize { @retry = nil }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def generation_feedback_finished
|
|
122
|
+
@lock.synchronize do
|
|
123
|
+
@thinking = false
|
|
124
|
+
@line.reset
|
|
125
|
+
@waiting_since = nil
|
|
126
|
+
redraw_status
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The turn's answer (or a merge's) is out: the slot goes (unless a
|
|
131
|
+
# plugin's init task still runs). The tally keeps counting until the
|
|
132
|
+
# next turn starts (a merge goes on).
|
|
133
|
+
def finish_thinking_spinner
|
|
134
|
+
@lock.synchronize do
|
|
135
|
+
reset_feedback_state
|
|
136
|
+
@init_tasks.empty? ? @screen.clear_slot(:activity) : redraw_status
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A plugin's init task runs (between turns too): the slot turns with
|
|
141
|
+
# its label until it ends.
|
|
142
|
+
# @param task [Hash] {bundle:, id:, label:} (an event or a snapshot's)
|
|
143
|
+
def init_started(task)
|
|
144
|
+
@lock.synchronize do
|
|
145
|
+
@init_tasks[task[:id].to_s] = "#{task[:bundle]}: #{task[:label]}"
|
|
146
|
+
redraw_status
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def init_finished(task)
|
|
151
|
+
@lock.synchronize do
|
|
152
|
+
@init_tasks.delete(task[:id].to_s)
|
|
153
|
+
redraw_status
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The turn waits for them: the slot already shows them.
|
|
158
|
+
def init_wait_feedback(_event); end
|
|
159
|
+
|
|
160
|
+
# Pick up a turn joined mid-way (from the Bridge snapshot): the model's
|
|
161
|
+
# thinking or text so far (+lane+ :thinking or :writing), or the tool it
|
|
162
|
+
# is running, and the tally of its tool parts.
|
|
163
|
+
def resume(tail: nil, lane: :writing, tool: nil, parts: nil)
|
|
164
|
+
@lock.synchronize do
|
|
165
|
+
@tally.reset.seed(parts) if parts
|
|
166
|
+
@thinking = true
|
|
167
|
+
@line.reset
|
|
168
|
+
@line.resume(lane, tail)
|
|
169
|
+
@tool = tool && "running #{tool}"
|
|
170
|
+
@waiting_since = nil
|
|
171
|
+
redraw_status
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Redraw the slot for the time that passed (the ticker calls it).
|
|
176
|
+
# @return [Boolean] whether the slot is still shown
|
|
177
|
+
def tick
|
|
178
|
+
@lock.synchronize do
|
|
179
|
+
return false unless status_text
|
|
180
|
+
|
|
181
|
+
redraw_status
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# End the ticker (the loop is over; nothing may draw after it).
|
|
187
|
+
def stop
|
|
188
|
+
ticker = @lock.synchronize { @ticker.tap { @ticker = nil } }
|
|
189
|
+
ticker&.kill
|
|
190
|
+
ticker&.join(0.5)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def capture_context_status(status)
|
|
194
|
+
@context_status = status if status
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The REPL's sticky memories line has no source in attached mode.
|
|
198
|
+
def emit_active_memories_line; end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def reset_feedback_state
|
|
203
|
+
@thinking = false
|
|
204
|
+
@line.reset
|
|
205
|
+
@tool = nil
|
|
206
|
+
@retry = nil
|
|
207
|
+
@waiting_since = nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def tally_key(event)
|
|
211
|
+
[event[:iteration].to_i, event[:call_index].to_i]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def redraw_status(throttle: false)
|
|
215
|
+
now = @clock.call
|
|
216
|
+
return if throttle && @last_redraw && (now - @last_redraw) < MIN_REDRAW_INTERVAL
|
|
217
|
+
|
|
218
|
+
@last_redraw = now
|
|
219
|
+
@line.tick
|
|
220
|
+
text = status_text
|
|
221
|
+
if text
|
|
222
|
+
tally = @tally.text(width: @screen.columns - 1)
|
|
223
|
+
@screen.set_slot(:activity, tally ? [text, paint(tally, 90)] : [text])
|
|
224
|
+
start_ticker
|
|
225
|
+
else
|
|
226
|
+
@screen.clear_slot(:activity)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# One thread per shown slot; it ends when the slot goes (see #tick).
|
|
231
|
+
def start_ticker
|
|
232
|
+
return unless @tick_interval
|
|
233
|
+
return if @ticker&.alive?
|
|
234
|
+
|
|
235
|
+
@ticker = Thread.new do
|
|
236
|
+
loop do
|
|
237
|
+
sleep(@tick_interval)
|
|
238
|
+
break unless tick
|
|
239
|
+
end
|
|
240
|
+
rescue StandardError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
@ticker.report_on_exception = false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def status_text
|
|
247
|
+
now = @clock.call
|
|
248
|
+
frame = FRAMES[((now - @origin) / FRAME_INTERVAL).floor % FRAMES.length]
|
|
249
|
+
return "#{frame} #{@retry}" if @retry
|
|
250
|
+
return "#{frame} #{@tool}…" if @tool
|
|
251
|
+
return "#{frame} #{@init_tasks.values.join(" · ")}…" if !@thinking && @init_tasks.any?
|
|
252
|
+
return nil unless @thinking
|
|
253
|
+
|
|
254
|
+
waited = @waiting_since ? now - @waiting_since : 0
|
|
255
|
+
return "#{frame} waiting for the first token… #{waited.floor}s" if waited >= WAIT_NOTICE_AFTER
|
|
256
|
+
|
|
257
|
+
return "#{frame} thinking…" if @line.empty?
|
|
258
|
+
|
|
259
|
+
prefix = "#{frame} #{@line.label} · "
|
|
260
|
+
prefix + paint(@line.fit(@screen.columns - 1 - prefix.length), 90)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# Renders one UI's view of Engine turn events: the thinking spinner while
|
|
8
|
+
# the model generates, a `tool>` line per completed tool call, and the
|
|
9
|
+
# answer (with any tool activity not already shown) from :turn_completed's
|
|
10
|
+
# turn_summary. It is the `on_event:` sink the REPL hands Engine#run_turn.
|
|
11
|
+
#
|
|
12
|
+
# Drawing is delegated to a +view+ (the TerminalUI), so the same dispatch
|
|
13
|
+
# can later render events that arrive over the Bridge. The renderer keeps
|
|
14
|
+
# only per-turn bookkeeping that must come from the events themselves:
|
|
15
|
+
# which tool lines were already streamed, and when each tool call started.
|
|
16
|
+
# Events that came over the Bridge as JSON (string keys) are symbolized first.
|
|
17
|
+
class EventRenderer
|
|
18
|
+
def initialize(view, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
|
+
@view = view
|
|
20
|
+
@clock = clock
|
|
21
|
+
@streamed_tool_activity = Hash.new(0)
|
|
22
|
+
@tool_started_at = {}
|
|
23
|
+
# Card ids shown so far (across turns): one shown again is updated.
|
|
24
|
+
@card_ids = Set.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Deep-convert a string-keyed event (JSON from the Bridge) to the local
|
|
28
|
+
# shape: symbol keys and a symbol :type. Other values stay as they are.
|
|
29
|
+
def self.symbolize(event)
|
|
30
|
+
event = deep_symbolize_keys(event)
|
|
31
|
+
event[:type] = event[:type].to_sym if event[:type].is_a?(String)
|
|
32
|
+
event
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.deep_symbolize_keys(value)
|
|
36
|
+
case value
|
|
37
|
+
when Hash then value.to_h { |k, v| [k.is_a?(String) ? k.to_sym : k, deep_symbolize_keys(v)] }
|
|
38
|
+
when Array then value.map { |v| deep_symbolize_keys(v) }
|
|
39
|
+
else value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(event)
|
|
44
|
+
event = self.class.symbolize(event) if event.key?("type")
|
|
45
|
+
case event[:type]
|
|
46
|
+
when :turn_started
|
|
47
|
+
begin_turn
|
|
48
|
+
Array(event[:images]).each { |ref| @view.print_line(@view.format_image_line(ref)) }
|
|
49
|
+
when :generation_started
|
|
50
|
+
@view.generation_feedback_started(event)
|
|
51
|
+
when :generation_retrying
|
|
52
|
+
@view.generation_feedback_retrying(event)
|
|
53
|
+
when :generation_chunk
|
|
54
|
+
@view.generation_feedback_chunk(event)
|
|
55
|
+
when :tool_call_started
|
|
56
|
+
@tool_started_at[tool_call_key(event)] = @clock.call
|
|
57
|
+
@view.tool_call_feedback_started(event)
|
|
58
|
+
when :tool_call_completed
|
|
59
|
+
@view.clear_generation_retry
|
|
60
|
+
@view.tool_call_feedback_completed(event)
|
|
61
|
+
render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images])
|
|
62
|
+
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
63
|
+
@view.generation_feedback_finished
|
|
64
|
+
when :pending_input_merged
|
|
65
|
+
render_merge(event)
|
|
66
|
+
when :turn_completed
|
|
67
|
+
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
68
|
+
when :guardrail_warning
|
|
69
|
+
@view.print_line(self.class.load_warning_line(event))
|
|
70
|
+
when :hook_notice
|
|
71
|
+
@view.print_line(self.class.hook_notice_line(event))
|
|
72
|
+
when :card
|
|
73
|
+
render_card(event)
|
|
74
|
+
# A turn waits for plugins' slow setup before its first request.
|
|
75
|
+
when :plugin_init_wait
|
|
76
|
+
@view.init_wait_feedback(event)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Print a card (a :card event or a snapshot's card) as a framed block;
|
|
81
|
+
# one whose id was shown before is printed again, marked (updated).
|
|
82
|
+
# @param updated [Boolean] marked (updated) anyway (a snapshot's card)
|
|
83
|
+
def render_card(card, updated: false)
|
|
84
|
+
id = card[:id].to_s
|
|
85
|
+
updated ||= !id.empty? && @card_ids.include?(id)
|
|
86
|
+
@card_ids << id unless id.empty?
|
|
87
|
+
@view.print_line(@view.card_block(card, updated: updated))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Boolean] whether a card with this id was shown here
|
|
91
|
+
def card_shown?(id) = @card_ids.include?(id.to_s)
|
|
92
|
+
|
|
93
|
+
# What failed to load, labelled by what it is: guardrails (the
|
|
94
|
+
# default) or plugins.
|
|
95
|
+
def self.load_warning_line(event)
|
|
96
|
+
"#{event[:label] || "guardrails"}> #{event[:message]}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A hook's notice as one line: "<bundle>> text" for a bundle hook,
|
|
100
|
+
# "hook> text" for a config or turn hook (a warn one says so).
|
|
101
|
+
# @param event [Hash] :hook_notice event or a snapshot part with
|
|
102
|
+
# hook:, text:, level:
|
|
103
|
+
def self.hook_notice_line(event)
|
|
104
|
+
label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || "hook"
|
|
105
|
+
text = event[:text].to_s
|
|
106
|
+
text = "warning: #{text}" if event[:level].to_s == "warn"
|
|
107
|
+
"#{label}> #{text}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A plugin's init task (chi.init) as one line: "<bundle>> <label>…"
|
|
111
|
+
# as it starts, "<bundle>> ✓ <summary>" when it is done. A failed
|
|
112
|
+
# one is a warn card, not a line.
|
|
113
|
+
# @return [String, nil]
|
|
114
|
+
def self.init_line(event)
|
|
115
|
+
case event[:type].to_s
|
|
116
|
+
when "plugin_init_started" then "#{event[:bundle]}> #{event[:label]}…"
|
|
117
|
+
when "plugin_init_finished"
|
|
118
|
+
return nil unless event[:ok]
|
|
119
|
+
|
|
120
|
+
"#{event[:bundle]}> ✓ #{event[:summary] || "#{event[:label]}: done"}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Start a turn's bookkeeping and reset the view's per-turn feedback.
|
|
125
|
+
# Also called directly by REPL paths that drive the kernel without
|
|
126
|
+
# Engine#run_turn (so no :turn_started).
|
|
127
|
+
def begin_turn
|
|
128
|
+
@streamed_tool_activity = Hash.new(0)
|
|
129
|
+
@tool_started_at = {}
|
|
130
|
+
@view.reset_turn_feedback
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Render a finished turn: tool activity the stream did not already show,
|
|
134
|
+
# the status lines, the answer, and the iteration-limit notice.
|
|
135
|
+
# @param summary [Hash] Engine#turn_summary
|
|
136
|
+
def render_turn_summary(summary)
|
|
137
|
+
@view.finish_thinking_spinner
|
|
138
|
+
@view.capture_context_status(summary[:context_status])
|
|
139
|
+
Array(summary[:tool_activity]).each do |activity|
|
|
140
|
+
next if consume_streamed_tool_activity(activity)
|
|
141
|
+
|
|
142
|
+
@view.print_line(@view.format_tool_activity_line(activity))
|
|
143
|
+
end
|
|
144
|
+
@view.emit_active_memories_line
|
|
145
|
+
@view.print_line(summary[:output])
|
|
146
|
+
@view.print_line("iteration limit reached") if summary[:resumable]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Steering merged into the running turn: the answer it follows first
|
|
150
|
+
# (the turn summary shows only the last one), then the note.
|
|
151
|
+
def render_merge(event)
|
|
152
|
+
@view.finish_thinking_spinner
|
|
153
|
+
@view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
|
|
154
|
+
count = event[:count].to_i
|
|
155
|
+
@view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def render_streamed_tool_activity(activity, duration_ms:, images: nil)
|
|
161
|
+
return if activity.nil?
|
|
162
|
+
|
|
163
|
+
@streamed_tool_activity[tool_activity_key(activity)] += 1
|
|
164
|
+
line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
|
|
165
|
+
line += @view.format_tool_image_suffix(images) if images&.any?
|
|
166
|
+
@view.print_line(line)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def consume_streamed_tool_activity(activity)
|
|
170
|
+
key = tool_activity_key(activity)
|
|
171
|
+
count = @streamed_tool_activity[key]
|
|
172
|
+
return false unless count.positive?
|
|
173
|
+
|
|
174
|
+
@streamed_tool_activity[key] = count - 1
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def tool_activity_key(activity)
|
|
179
|
+
[activity[:action], activity[:tool], activity[:params], activity[:status]].map(&:to_s).join("|")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def tool_call_key(event)
|
|
183
|
+
[event[:iteration].to_i, event[:call_index].to_i, event[:tool].to_s]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def tool_duration_ms(event)
|
|
187
|
+
started_at = @tool_started_at.delete(tool_call_key(event))
|
|
188
|
+
started_at && ((@clock.call - started_at) * 1000.0)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|