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,466 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "time"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require_relative "session"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# SessionMetrics is a persistent, error-isolated collector of per-session
|
|
12
|
+
# analytics. It implements the same #call(event) sink contract as
|
|
13
|
+
# SessionObserver subscribers, so Engine#run_turn feeds it (it forwards every
|
|
14
|
+
# event to its SessionObserver): the REPL, the -p/--non-interactive/--resume
|
|
15
|
+
# paths and SessionManager background workers.
|
|
16
|
+
#
|
|
17
|
+
# Collected dimensions:
|
|
18
|
+
# - tokens (input/output/total), with a provenance flag (:server|:estimate)
|
|
19
|
+
# - turn count, tool calls (total / per-tool / error count)
|
|
20
|
+
# - iterations (tool-call rounds) per turn, aggregated
|
|
21
|
+
# - generation latency (monotonic clock across generation_* events)
|
|
22
|
+
# - cancellations and network retries
|
|
23
|
+
# - session wall-clock (first activity -> last activity)
|
|
24
|
+
#
|
|
25
|
+
# A snapshot is surfaced via Engine#session_state_snapshot and (optionally)
|
|
26
|
+
# persisted to a sibling analytics.json next to the session file. The event
|
|
27
|
+
# trail itself goes to the debug log (LogSubscriber).
|
|
28
|
+
class SessionMetrics
|
|
29
|
+
# Per-turn transient state, reset on each turn_started.
|
|
30
|
+
TurnState = Struct.new(
|
|
31
|
+
:session_id,
|
|
32
|
+
:iteration_count,
|
|
33
|
+
:gen_started_at,
|
|
34
|
+
:gen_latency_accum,
|
|
35
|
+
:tool_calls,
|
|
36
|
+
:tool_errors,
|
|
37
|
+
# Per-generation completion-token tracking. A turn may contain several
|
|
38
|
+
# generations (one per tool-call iteration); each generation's predicted_n
|
|
39
|
+
# is cumulative within that generation and resets afterwards, so totals
|
|
40
|
+
# must be summed per-generation (not taken as a global max).
|
|
41
|
+
:gen_completion_max,
|
|
42
|
+
:gen_had_server,
|
|
43
|
+
# Buffered chars/4 estimate for the current generation; committed to the
|
|
44
|
+
# turn total only if the generation ends with no server token data.
|
|
45
|
+
:gen_estimate_sum,
|
|
46
|
+
:id,
|
|
47
|
+
:started_at,
|
|
48
|
+
:started_monotonic,
|
|
49
|
+
:tool_calls_by_id,
|
|
50
|
+
keyword_init: true
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def initialize(clock: nil, wall_clock: nil)
|
|
54
|
+
@mutex = Monitor.new
|
|
55
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
56
|
+
@wall_clock = wall_clock || -> { Time.now }
|
|
57
|
+
@session_id = nil
|
|
58
|
+
@turns = 0
|
|
59
|
+
@tokens_in = 0
|
|
60
|
+
@tokens_out = 0
|
|
61
|
+
@tokens_total = 0
|
|
62
|
+
@token_source = nil # :server | :estimate
|
|
63
|
+
@tool_calls_total = 0
|
|
64
|
+
@tool_calls_by_tool = Hash.new(0)
|
|
65
|
+
@tool_errors = 0
|
|
66
|
+
@iterations_total = 0
|
|
67
|
+
@gen_latency_ms = 0
|
|
68
|
+
@cancellations = 0
|
|
69
|
+
@retries = 0
|
|
70
|
+
@started_at = nil
|
|
71
|
+
@last_activity_at = nil
|
|
72
|
+
@turn = nil
|
|
73
|
+
@turn_records = []
|
|
74
|
+
@tool_records = []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# /model: the window and prompt profile a turn reported were the old
|
|
78
|
+
# model's; /stats resolves the new one's until its first turn reports.
|
|
79
|
+
def forget_model_reports!
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
@context_window_tokens = @context_window_source = nil
|
|
82
|
+
@profile = @profile_source = nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Set the session id the collector is aggregating for. Safe to call multiple
|
|
87
|
+
# times; the first non-empty id wins so later turns don't clobber it.
|
|
88
|
+
# @param id [String, nil]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def session_id=(id)
|
|
91
|
+
return if id.nil? || id.to_s.empty?
|
|
92
|
+
|
|
93
|
+
@mutex.synchronize { @session_id ||= id.to_s }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Event sink. Safe to call from any thread; errors are isolated by the
|
|
97
|
+
# caller (SessionObserver / TerminalUI) but we still guard internally.
|
|
98
|
+
# @param event [Hash]
|
|
99
|
+
# @return [void]
|
|
100
|
+
def call(event)
|
|
101
|
+
return unless event.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
type = event[:type]
|
|
104
|
+
case type
|
|
105
|
+
when :turn_started
|
|
106
|
+
begin_turn(event)
|
|
107
|
+
when :generation_chunk
|
|
108
|
+
accumulate_tokens(event)
|
|
109
|
+
when :tool_dispatch_started
|
|
110
|
+
@mutex.synchronize { @turn&.iteration_count += 1 }
|
|
111
|
+
when :tool_call_started
|
|
112
|
+
record_tool_call_start(event)
|
|
113
|
+
when :tool_call_completed
|
|
114
|
+
record_tool_call_completed(event)
|
|
115
|
+
when :generation_started
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
if event[:context_window_tokens]
|
|
118
|
+
@context_window_tokens = event[:context_window_tokens]
|
|
119
|
+
@context_window_source = event[:context_window_source]
|
|
120
|
+
end
|
|
121
|
+
if event[:profile]
|
|
122
|
+
@profile = event[:profile]
|
|
123
|
+
@profile_source = event[:profile_source]
|
|
124
|
+
end
|
|
125
|
+
if @turn
|
|
126
|
+
@turn.gen_started_at = monotonic_time
|
|
127
|
+
@turn.gen_completion_max = 0
|
|
128
|
+
@turn.gen_had_server = false
|
|
129
|
+
@turn.gen_estimate_sum = 0
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
when :generation_completed, :generation_cancelled
|
|
133
|
+
record_served_model(event)
|
|
134
|
+
record_generation_completed
|
|
135
|
+
when :generation_retrying
|
|
136
|
+
@mutex.synchronize { @retries += 1 }
|
|
137
|
+
when :turn_completed
|
|
138
|
+
end_turn(status: "completed")
|
|
139
|
+
when :turn_canceled
|
|
140
|
+
@mutex.synchronize { @cancellations += 1 }
|
|
141
|
+
end_turn(status: "canceled", reason: event[:cancellation_reason])
|
|
142
|
+
when :turn_failed
|
|
143
|
+
end_turn(status: "failed")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@mutex.synchronize { @last_activity_at = now.iso8601(3) }
|
|
147
|
+
rescue StandardError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [Hash] the current summary snapshot
|
|
152
|
+
def snapshot
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
{
|
|
155
|
+
session_id: @session_id,
|
|
156
|
+
turns: @turns,
|
|
157
|
+
tokens_in: @tokens_in,
|
|
158
|
+
tokens_out: @tokens_out,
|
|
159
|
+
tokens_total: @tokens_total,
|
|
160
|
+
token_source: @token_source,
|
|
161
|
+
context_window_tokens: @context_window_tokens,
|
|
162
|
+
context_window_source: @context_window_source,
|
|
163
|
+
profile: @profile,
|
|
164
|
+
profile_source: @profile_source,
|
|
165
|
+
served_model: @served_model,
|
|
166
|
+
served_model_for: @served_model_for,
|
|
167
|
+
# The running turn's calls, iterations and finished generations
|
|
168
|
+
# count at once (the per-tool counts do too), so /stats agrees
|
|
169
|
+
# mid-turn; end_turn moves them into the totals.
|
|
170
|
+
tool_calls_total: @tool_calls_total + (@turn&.tool_calls || 0),
|
|
171
|
+
tool_calls_by_tool: @tool_calls_by_tool.dup,
|
|
172
|
+
tool_errors: @tool_errors + (@turn&.tool_errors || 0),
|
|
173
|
+
iterations_total: @iterations_total + (@turn&.iteration_count || 0),
|
|
174
|
+
gen_latency_ms: @gen_latency_ms + (@turn&.gen_latency_accum || 0),
|
|
175
|
+
cancellations: @cancellations,
|
|
176
|
+
retries: @retries,
|
|
177
|
+
started_at: @started_at,
|
|
178
|
+
last_activity_at: @last_activity_at,
|
|
179
|
+
session_duration_ms: elapsed_ms(@session_started_monotonic),
|
|
180
|
+
turn_records: @turn_records.map(&:dup),
|
|
181
|
+
tool_records: @tool_records.map(&:dup),
|
|
182
|
+
active_turn: active_turn_snapshot,
|
|
183
|
+
active_tools: active_tool_snapshots
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Persist the summary snapshot to a sibling analytics.json in the session
|
|
189
|
+
# directory. Atomic write; failures are swallowed (analytics must never
|
|
190
|
+
# break the running session).
|
|
191
|
+
# @param state_dir [String, nil]
|
|
192
|
+
# @return [Boolean] true on success
|
|
193
|
+
def persist(state_dir: nil)
|
|
194
|
+
sid = @mutex.synchronize { @session_id }
|
|
195
|
+
return false if sid.nil? || sid.to_s.empty?
|
|
196
|
+
|
|
197
|
+
# Omitting state_dir lets Session.session_dir fall back to the default
|
|
198
|
+
# (XDG) location; passing an explicit nil would override it and break
|
|
199
|
+
# File.join.
|
|
200
|
+
dir = state_dir ? Session.session_dir(sid, state_dir: state_dir) : Session.session_dir(sid)
|
|
201
|
+
FileUtils.mkdir_p(dir)
|
|
202
|
+
path = File.join(dir, "analytics.json")
|
|
203
|
+
temp_path = "#{path}.tmp"
|
|
204
|
+
File.write(temp_path, JSON.pretty_generate(merged_persisted_snapshot(path)) + "\n")
|
|
205
|
+
File.rename(temp_path, path)
|
|
206
|
+
true
|
|
207
|
+
rescue StandardError
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def begin_turn(event)
|
|
214
|
+
@mutex.synchronize do
|
|
215
|
+
@session_id ||= event[:session_id].to_s if event[:session_id]
|
|
216
|
+
@started_at ||= now.iso8601(3)
|
|
217
|
+
@session_started_monotonic ||= monotonic_time
|
|
218
|
+
@turns += 1
|
|
219
|
+
@turn = TurnState.new(
|
|
220
|
+
session_id: @session_id,
|
|
221
|
+
iteration_count: 0,
|
|
222
|
+
gen_started_at: nil,
|
|
223
|
+
gen_latency_accum: 0,
|
|
224
|
+
tool_calls: 0,
|
|
225
|
+
tool_errors: 0,
|
|
226
|
+
gen_completion_max: 0,
|
|
227
|
+
gen_had_server: false,
|
|
228
|
+
gen_estimate_sum: 0,
|
|
229
|
+
id: SecureRandom.uuid,
|
|
230
|
+
started_at: now.iso8601(3),
|
|
231
|
+
started_monotonic: monotonic_time,
|
|
232
|
+
tool_calls_by_id: {}
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...), so
|
|
238
|
+
# a reloaded web history can show the cancel line the live one did.
|
|
239
|
+
def end_turn(status: "completed", reason: nil)
|
|
240
|
+
@mutex.synchronize do
|
|
241
|
+
if @turn
|
|
242
|
+
finished_at = now
|
|
243
|
+
record = {
|
|
244
|
+
id: @turn.id,
|
|
245
|
+
status: status,
|
|
246
|
+
started_at: @turn.started_at,
|
|
247
|
+
completed_at: finished_at.iso8601(3),
|
|
248
|
+
duration_ms: elapsed_ms(@turn.started_monotonic)
|
|
249
|
+
}
|
|
250
|
+
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
251
|
+
@turn_records << record
|
|
252
|
+
end
|
|
253
|
+
@iterations_total += @turn.iteration_count if @turn
|
|
254
|
+
@gen_latency_ms += @turn.gen_latency_accum if @turn
|
|
255
|
+
@tool_calls_total += @turn.tool_calls if @turn
|
|
256
|
+
@tool_errors += @turn.tool_errors if @turn
|
|
257
|
+
@turn = nil
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# The model the server says answered, and the name asked for then (a
|
|
262
|
+
# /model switch makes it stale until the next generation reports).
|
|
263
|
+
def record_served_model(event)
|
|
264
|
+
return unless event[:served_model]
|
|
265
|
+
|
|
266
|
+
@mutex.synchronize do
|
|
267
|
+
@served_model = event[:served_model]
|
|
268
|
+
@served_model_for = event[:requested_model]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def record_generation_completed
|
|
273
|
+
# Finalize this generation's completion tokens. Server timings are
|
|
274
|
+
# cumulative per generation; we sum the per-generation max into the turn
|
|
275
|
+
# total. If the generation reported no server tokens we commit the buffered
|
|
276
|
+
# chars/4 estimate instead. The two paths are mutually exclusive per
|
|
277
|
+
# generation, so a final chunk carrying timings while earlier chunks did
|
|
278
|
+
# not will not double count.
|
|
279
|
+
@mutex.synchronize do
|
|
280
|
+
return unless @turn
|
|
281
|
+
|
|
282
|
+
if @turn.gen_had_server
|
|
283
|
+
@tokens_out += @turn.gen_completion_max
|
|
284
|
+
else
|
|
285
|
+
@tokens_out += @turn.gen_estimate_sum
|
|
286
|
+
end
|
|
287
|
+
@tokens_total = @tokens_in + @tokens_out
|
|
288
|
+
started = @turn.gen_started_at
|
|
289
|
+
if started
|
|
290
|
+
elapsed_ms = (monotonic_time - started) * 1000.0
|
|
291
|
+
@turn.gen_latency_accum += elapsed_ms if elapsed_ms > 0
|
|
292
|
+
@turn.gen_started_at = nil
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def record_tool_call_start(event)
|
|
298
|
+
@mutex.synchronize do
|
|
299
|
+
return unless @turn
|
|
300
|
+
|
|
301
|
+
@turn.tool_calls += 1
|
|
302
|
+
tool = event[:tool].to_s
|
|
303
|
+
@tool_calls_by_tool[tool] += 1 unless tool.empty?
|
|
304
|
+
iteration = event[:iteration].to_i
|
|
305
|
+
call_index = event[:call_index].to_i
|
|
306
|
+
key = tool_key(iteration, call_index)
|
|
307
|
+
@turn.tool_calls_by_id[key] = {
|
|
308
|
+
id: "#{@turn.id}:#{key}",
|
|
309
|
+
turn_id: @turn.id,
|
|
310
|
+
iteration: iteration,
|
|
311
|
+
call_index: call_index,
|
|
312
|
+
tool: tool,
|
|
313
|
+
started_at: now.iso8601(3),
|
|
314
|
+
started_monotonic: monotonic_time
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def record_tool_call_completed(event)
|
|
320
|
+
@mutex.synchronize do
|
|
321
|
+
return unless @turn
|
|
322
|
+
|
|
323
|
+
status = event.dig(:activity, :status).to_s
|
|
324
|
+
status = event[:status].to_s if status.empty?
|
|
325
|
+
@turn.tool_errors += 1 if status == "error"
|
|
326
|
+
key = tool_key(event[:iteration].to_i, event[:call_index].to_i)
|
|
327
|
+
active = @turn.tool_calls_by_id.delete(key)
|
|
328
|
+
return unless active
|
|
329
|
+
|
|
330
|
+
finished_at = now
|
|
331
|
+
@tool_records << active.slice(
|
|
332
|
+
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
333
|
+
).merge(
|
|
334
|
+
status: status.empty? ? "ok" : status,
|
|
335
|
+
completed_at: finished_at.iso8601(3),
|
|
336
|
+
duration_ms: elapsed_ms(active[:started_monotonic])
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Accumulate token counts from a streamed generation_chunk payload.
|
|
342
|
+
# Server-first: prefer real timings/usage. Input tokens keep a running MAX
|
|
343
|
+
# (the prompt grows across tool-call iterations); completion tokens are
|
|
344
|
+
# tracked as a per-generation MAX and summed at generation_completed so
|
|
345
|
+
# multi-generation turns count every generation. When a generation reports
|
|
346
|
+
# no server tokens at all we fall back to the chars/4 estimate summed across
|
|
347
|
+
# its chunks (mutually exclusive with server counting to avoid double
|
|
348
|
+
# counting a final chunk that carries timings while earlier chunks do not).
|
|
349
|
+
def accumulate_tokens(event)
|
|
350
|
+
payload = event[:payload]
|
|
351
|
+
payload = {} unless payload.is_a?(Hash)
|
|
352
|
+
|
|
353
|
+
usage = TokenUsage.from_payload(payload)
|
|
354
|
+
if usage
|
|
355
|
+
@mutex.synchronize do
|
|
356
|
+
@token_source = :server
|
|
357
|
+
@tokens_in = [@tokens_in, usage[:prompt_tokens].to_i].max
|
|
358
|
+
if @turn
|
|
359
|
+
@turn.gen_completion_max = [@turn.gen_completion_max, usage[:completion_tokens].to_i].max
|
|
360
|
+
@turn.gen_had_server = true
|
|
361
|
+
end
|
|
362
|
+
@tokens_total = @tokens_in + @tokens_out
|
|
363
|
+
end
|
|
364
|
+
else
|
|
365
|
+
@mutex.synchronize do
|
|
366
|
+
chars = event_content_chars(payload, event)
|
|
367
|
+
return unless chars && chars.positive?
|
|
368
|
+
return if @turn && @turn.gen_had_server
|
|
369
|
+
|
|
370
|
+
@token_source ||= :estimate
|
|
371
|
+
@turn.gen_estimate_sum += TokenUsage.estimate(payload_content(payload, event)) if @turn
|
|
372
|
+
@tokens_total = @tokens_in + @tokens_out
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def event_content_chars(payload, event)
|
|
378
|
+
len = payload_content(payload, event).length
|
|
379
|
+
len.positive? ? len : nil
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def payload_content(payload, event = nil)
|
|
383
|
+
content = payload["content"] || payload[:content]
|
|
384
|
+
return content if content.is_a?(String)
|
|
385
|
+
|
|
386
|
+
event_content = event && event[:content]
|
|
387
|
+
event_content.is_a?(String) ? event_content : ""
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def monotonic_time
|
|
391
|
+
@clock.call
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def now
|
|
395
|
+
@wall_clock.call
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def elapsed_ms(started)
|
|
399
|
+
return 0 unless started
|
|
400
|
+
|
|
401
|
+
[((monotonic_time - started) * 1000.0).round, 0].max
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def tool_key(iteration, call_index)
|
|
405
|
+
"#{iteration}:#{call_index}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def active_turn_snapshot
|
|
409
|
+
return nil unless @turn
|
|
410
|
+
|
|
411
|
+
{
|
|
412
|
+
id: @turn.id,
|
|
413
|
+
started_at: @turn.started_at,
|
|
414
|
+
duration_ms: elapsed_ms(@turn.started_monotonic)
|
|
415
|
+
}
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def active_tool_snapshots
|
|
419
|
+
return [] unless @turn
|
|
420
|
+
|
|
421
|
+
@turn.tool_calls_by_id.values.map do |tool|
|
|
422
|
+
tool.slice(:id, :turn_id, :iteration, :call_index, :tool, :started_at).merge(
|
|
423
|
+
duration_ms: elapsed_ms(tool[:started_monotonic])
|
|
424
|
+
)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def merged_persisted_snapshot(path)
|
|
429
|
+
current = snapshot.merge(active_turn: nil, active_tools: [])
|
|
430
|
+
return current unless File.file?(path)
|
|
431
|
+
|
|
432
|
+
prior = JSON.parse(File.read(path))
|
|
433
|
+
return current unless prior.is_a?(Hash)
|
|
434
|
+
|
|
435
|
+
current.merge(
|
|
436
|
+
started_at: earliest_timestamp(prior["started_at"], current[:started_at]),
|
|
437
|
+
last_activity_at: latest_timestamp(prior["last_activity_at"], current[:last_activity_at]),
|
|
438
|
+
turn_records: merge_records(prior["turn_records"], current[:turn_records]),
|
|
439
|
+
tool_records: merge_records(prior["tool_records"], current[:tool_records])
|
|
440
|
+
)
|
|
441
|
+
rescue JSON::ParserError
|
|
442
|
+
current
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def merge_records(prior, current)
|
|
446
|
+
(Array(prior) + Array(current)).each_with_object({}) do |record, by_id|
|
|
447
|
+
next unless record.is_a?(Hash)
|
|
448
|
+
|
|
449
|
+
id = record["id"] || record[:id]
|
|
450
|
+
by_id[id] = record if id
|
|
451
|
+
end.values
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def earliest_timestamp(*timestamps)
|
|
455
|
+
timestamps.compact.min_by { |value| Time.iso8601(value.to_s) }
|
|
456
|
+
rescue ArgumentError
|
|
457
|
+
timestamps.compact.first
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def latest_timestamp(*timestamps)
|
|
461
|
+
timestamps.compact.max_by { |value| Time.iso8601(value.to_s) }
|
|
462
|
+
rescue ArgumentError
|
|
463
|
+
timestamps.compact.last
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# SessionObserver is a small persistent subscriber registry.
|
|
7
|
+
#
|
|
8
|
+
# Unlike the turn-scoped `Engine#run_turn` `on_event:` sink (passed once per
|
|
9
|
+
# turn and gone when the turn ends), a subscriber registered here keeps
|
|
10
|
+
# receiving events across every `run_turn` call on the same Engine instance.
|
|
11
|
+
#
|
|
12
|
+
# Each delivery carries a locally-monotonic `event_seq` so a subscriber can
|
|
13
|
+
# track ordering and how far behind it is. Subscribers are error-isolated:
|
|
14
|
+
# a subscriber that raises does not stop other subscribers from receiving the
|
|
15
|
+
# same event, and does not break the running turn.
|
|
16
|
+
#
|
|
17
|
+
# Documented event vocabulary (subscribers match on `:type`):
|
|
18
|
+
# * turn_started / turn_completed / turn_canceled / turn_failed — turn
|
|
19
|
+
# boundaries (turn_completed also carries a JSON-safe `turn_summary:`)
|
|
20
|
+
# * generation_* / tool_call_* / tool_dispatch_* — raw kernel-loop events
|
|
21
|
+
# * session_activity — an activity tick was recorded (see Engine#record_activity)
|
|
22
|
+
# * recap_ready — an idle session-recap finished generating
|
|
23
|
+
# (`recap:` prose, `generation:` id). Purely additive: it never mutates
|
|
24
|
+
# session.messages; each UI renders it (or not) however it likes.
|
|
25
|
+
class SessionObserver
|
|
26
|
+
# Handle returned by #subscribe. Holds the observer reference and carries
|
|
27
|
+
# #unsubscribe so a caller can deregister without keeping the registry.
|
|
28
|
+
class SubscribedObserver
|
|
29
|
+
# @return [#call] the wrapped observer
|
|
30
|
+
attr_reader :observer
|
|
31
|
+
|
|
32
|
+
def initialize(observer, registry)
|
|
33
|
+
@observer = observer
|
|
34
|
+
@registry = registry
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean, nil] true when this handle has unsubscribed, otherwise nil
|
|
38
|
+
def unsubscribed?
|
|
39
|
+
@unsubscribed
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Deregister this subscriber. Idempotent and safe to call multiple times.
|
|
43
|
+
# @return [Boolean] true if it was removed, false if already gone
|
|
44
|
+
def unsubscribe
|
|
45
|
+
return false if @unsubscribed
|
|
46
|
+
|
|
47
|
+
@unsubscribed = true
|
|
48
|
+
@registry&.unsubscribe(handle: self)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@mutex = Monitor.new
|
|
54
|
+
@observers = [] # Array<SubscribedObserver>
|
|
55
|
+
@seq = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Register a persistent subscriber (any object responding to #call(event)).
|
|
59
|
+
# @param observer [#call] the sink
|
|
60
|
+
# @return [SubscribedObserver] handle usable with #unsubscribe
|
|
61
|
+
def subscribe(observer:)
|
|
62
|
+
handle = SubscribedObserver.new(observer, self)
|
|
63
|
+
@mutex.synchronize { @observers << handle }
|
|
64
|
+
handle
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Remove a previously-registered subscriber.
|
|
68
|
+
# @param handle [SubscribedObserver, nil]
|
|
69
|
+
# @return [Boolean] true if it was removed, false otherwise (nil/unknown/
|
|
70
|
+
# already-unsubscribed never raises)
|
|
71
|
+
def unsubscribe(handle:)
|
|
72
|
+
return false unless handle.is_a?(SubscribedObserver)
|
|
73
|
+
@mutex.synchronize { !!@observers.delete(handle) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Assign a monotonic event_seq and fan out to every current subscriber.
|
|
77
|
+
# @param event [Hash] the raw emitted event (delivered to observers as a
|
|
78
|
+
# copy with `event_seq:` merged in; never mutated in place)
|
|
79
|
+
# @return [void]
|
|
80
|
+
def notify(event)
|
|
81
|
+
# Number AND deliver under one lock. Events arrive from several threads
|
|
82
|
+
# (turn, idle scheduler, bridge HTTP); delivering outside the lock let
|
|
83
|
+
# seq N reach a subscriber after N+1 (the SSE writer then dropped it as
|
|
84
|
+
# below its high-water mark) and let a numbered event fall between a
|
|
85
|
+
# new connection's replay snapshot and its live queue. Subscribers must
|
|
86
|
+
# stay non-blocking (enqueue-only), so holding the lock is cheap. The
|
|
87
|
+
# Monitor is reentrant, so a subscriber that emits on the same thread
|
|
88
|
+
# does not deadlock.
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
payload = event.merge(event_seq: @seq += 1)
|
|
91
|
+
|
|
92
|
+
@observers.dup.each do |handle|
|
|
93
|
+
next if handle.unsubscribed?
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
handle.observer.call(payload)
|
|
97
|
+
rescue StandardError
|
|
98
|
+
# Error-isolate: a throwing subscriber must not stop others or break
|
|
99
|
+
# the running turn.
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Integer] total events emitted so far (Engine-local sequence)
|
|
106
|
+
def event_count
|
|
107
|
+
@mutex.synchronize { @seq }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Run the block while no event is being numbered or delivered, so it sees
|
|
111
|
+
# (and can change) state consistently with the event log. Reentrant: the
|
|
112
|
+
# block may notify. Keep it short; every emitter waits for it.
|
|
113
|
+
def synchronize(&block)
|
|
114
|
+
@mutex.synchronize(&block)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../model_profile"
|
|
6
|
+
require_relative "../session_manager"
|
|
7
|
+
require_relative "../bridge_client"
|
|
8
|
+
require_relative "live_region"
|
|
9
|
+
require_relative "plain_surface"
|
|
10
|
+
require_relative "attached_loop"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
class TerminalUI
|
|
14
|
+
# `chi --attach ID` and `chi --shared [--resume ID]`: find or start the
|
|
15
|
+
# session's worker, then run the TUI as a client of its Bridge
|
|
16
|
+
# (AttachedLoop). It takes no OwnerLock and builds no Engine: the worker
|
|
17
|
+
# owns the session, and any number of UIs can attach to it.
|
|
18
|
+
module AttachLauncher
|
|
19
|
+
# Why attaching failed, worded for the terminal.
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
|
|
22
|
+
BRIDGE_WAIT = 10.0
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Attach until the user detaches or the worker goes away.
|
|
27
|
+
# @param prompt [String, nil] sent as the first prompt once attached (`-p`)
|
|
28
|
+
# @param model [String, nil] --model: a new session starts on it; a
|
|
29
|
+
# resumed or attached one's worker gets a /model before the prompt
|
|
30
|
+
# @param no_interrupt [Boolean] --no-interrupt: every turn this TUI
|
|
31
|
+
# posts runs with the raised iteration limit
|
|
32
|
+
# @param default_input [Boolean] a new session with no -p gets
|
|
33
|
+
# SAMAGOTCHI_DEFAULT_INPUT in its first read (--no-default-input: no)
|
|
34
|
+
# @param memories [Array<String>] --memory: a new session's worker
|
|
35
|
+
# preloads them (an existing session keeps its own list)
|
|
36
|
+
# @param muted_memories [Array<String>] --mute: hidden from a new session
|
|
37
|
+
# @return [Symbol] :detached, :closed when the worker went away, or
|
|
38
|
+
# :failed when the --model switch didn't go through
|
|
39
|
+
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
40
|
+
memories: [], muted_memories: [])
|
|
41
|
+
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
42
|
+
memories: memories, muted_memories: muted_memories)
|
|
43
|
+
first_command = model && (attach || resume) ? "/model #{model}" : nil
|
|
44
|
+
surface = open_surface
|
|
45
|
+
begin
|
|
46
|
+
AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
|
|
47
|
+
first_command: first_command, no_interrupt: no_interrupt,
|
|
48
|
+
default_input: default_input && !prompt && !attach && !resume).run
|
|
49
|
+
ensure
|
|
50
|
+
close_surface(surface)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A live region (Screen, with Reline drawing into it) on a terminal
|
|
55
|
+
# that can show one, else plain append-only output.
|
|
56
|
+
# @return [Screen, PlainSurface]
|
|
57
|
+
def open_surface(out: $stdout, input: $stdin, env: ENV)
|
|
58
|
+
LiveRegion.open(out: out, input: input, env: env) || PlainSurface.new(out: out)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def close_surface(surface)
|
|
62
|
+
LiveRegion.close(surface)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [BridgeClient] a client for the live Bridge of the session
|
|
66
|
+
# @raise [Error]
|
|
67
|
+
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
68
|
+
memories: [], muted_memories: [], err: $stderr)
|
|
69
|
+
sd = state_dir || Session.default_state_dir
|
|
70
|
+
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
|
|
71
|
+
if attach
|
|
72
|
+
live = connect_existing(attach, sd)
|
|
73
|
+
return live if live
|
|
74
|
+
|
|
75
|
+
# Its worker exited when nobody used it (or never ran): wake one.
|
|
76
|
+
resume = attach
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
session = if resume
|
|
80
|
+
SessionManager.resume_session(resume, state_dir: sd)
|
|
81
|
+
else
|
|
82
|
+
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
83
|
+
memories: memories, muted_memories: muted_memories)
|
|
84
|
+
end
|
|
85
|
+
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
86
|
+
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
87
|
+
rescue SessionManager::OwnedByTUI
|
|
88
|
+
raise Error, "session #{resume} is open in a chi REPL; close it there first"
|
|
89
|
+
rescue ArgumentError => e
|
|
90
|
+
raise Error, e.message
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --model as the REPL reads it: an alias resolved, a host prefix kept.
|
|
94
|
+
def model_ref(model)
|
|
95
|
+
ModelProfile.required_model_name(ConfigFile.resolve_model_alias(model))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# An existing session's prompt is built from its own session fields:
|
|
99
|
+
# --memory/--mute given with --attach or --resume are ignored, with a
|
|
100
|
+
# line saying so (printed before the live region opens).
|
|
101
|
+
def warn_memory_flags_ignored(session_id, memories, muted_memories, err)
|
|
102
|
+
flags = []
|
|
103
|
+
flags << "--memory" unless Array(memories).empty?
|
|
104
|
+
flags << "--mute" unless Array(muted_memories).empty?
|
|
105
|
+
return if flags.empty?
|
|
106
|
+
|
|
107
|
+
verb = flags.size == 1 ? "applies" : "apply"
|
|
108
|
+
err.puts "(#{flags.join(' and ')} #{verb} to a new session; #{session_id}'s prompt is already built)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [BridgeClient, nil] the running worker's, or nil when none runs
|
|
112
|
+
def connect_existing(session_id, state_dir)
|
|
113
|
+
Session.load(session_id, state_dir: state_dir)
|
|
114
|
+
BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|