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,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "rack"
|
|
6
|
+
require "rackup/handler/webrick"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "webrick"
|
|
9
|
+
|
|
10
|
+
require_relative "app"
|
|
11
|
+
require_relative "session_hub"
|
|
12
|
+
require_relative "../project_scope"
|
|
13
|
+
require_relative "../log"
|
|
14
|
+
require_relative "../version"
|
|
15
|
+
|
|
16
|
+
module Samagotchi
|
|
17
|
+
module Web
|
|
18
|
+
# Launcher for the single-port Web UI.
|
|
19
|
+
#
|
|
20
|
+
# Binds strictly to 127.0.0.1 (localhost-only). Use `bin/chi web` to start.
|
|
21
|
+
class Server
|
|
22
|
+
# WEBrick logs every exception out of its request loop as an ERROR with
|
|
23
|
+
# a backtrace, among them a browser dropping a kept-alive connection
|
|
24
|
+
# (sock.eof? raising ECONNRESET): harmless, so not logged.
|
|
25
|
+
class Log < WEBrick::Log
|
|
26
|
+
DROPPED = [Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED].freeze
|
|
27
|
+
|
|
28
|
+
def error(msg)
|
|
29
|
+
return if DROPPED.any? { |klass| msg.is_a?(klass) }
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# WEBrick logs the signal that ends its loop (Ctrl-C, a kill) as
|
|
35
|
+
# FATAL with a backtrace, then re-raises it: .launch says it in a
|
|
36
|
+
# line instead.
|
|
37
|
+
def fatal(msg)
|
|
38
|
+
return if msg.is_a?(SignalException)
|
|
39
|
+
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
DEFAULT_PORT = 4567
|
|
45
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
46
|
+
# How long `chi web` waits for a server already on its port to answer.
|
|
47
|
+
PROBE_TIMEOUT = 0.3
|
|
48
|
+
|
|
49
|
+
# `chi web`: one server serves every project, the scope is in the page
|
|
50
|
+
# URL. If a chi web already runs on the port, print (and with --open,
|
|
51
|
+
# open) its page for +dir+ and leave it be; else start one.
|
|
52
|
+
# @param scope ["project", "all"] "all": the plain page, every session
|
|
53
|
+
# @return [Integer] the exit status
|
|
54
|
+
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, turn_view: true)
|
|
55
|
+
host = resolve_host(host)
|
|
56
|
+
port = resolve_port(port)
|
|
57
|
+
url = scope_url(host, port, dir: dir, scope: scope)
|
|
58
|
+
found = probe(host, port)
|
|
59
|
+
case found
|
|
60
|
+
when Hash
|
|
61
|
+
puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
|
|
62
|
+
open_url(url) if open_browser
|
|
63
|
+
0
|
|
64
|
+
when :other
|
|
65
|
+
warn in_use_message(port)
|
|
66
|
+
1
|
|
67
|
+
else
|
|
68
|
+
start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, turn_view: turn_view) ? 0 : 1
|
|
69
|
+
end
|
|
70
|
+
rescue Interrupt
|
|
71
|
+
# Ctrl-C: the server has stopped (start's ensure); one line, and the
|
|
72
|
+
# status a shell gives a command it interrupted.
|
|
73
|
+
puts "#{"\n" if $stdout.tty?}Chi Web stopped."
|
|
74
|
+
130
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param hub [SessionHub, nil] the session projection the page streams
|
|
78
|
+
# from; built over the app's state dir unless given
|
|
79
|
+
# @return [Boolean] false when the port was taken (said so on stderr)
|
|
80
|
+
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
81
|
+
turn_view: true, hub: nil)
|
|
82
|
+
port = resolve_port(port)
|
|
83
|
+
host = resolve_host(host)
|
|
84
|
+
url ||= "http://#{url_host(host)}:#{port}/"
|
|
85
|
+
|
|
86
|
+
hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
|
|
87
|
+
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, turn_view: turn_view, hub: hub)
|
|
88
|
+
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
89
|
+
hub.start
|
|
90
|
+
# Said once the port is bound: a second chi web racing for it gets
|
|
91
|
+
# the in-use line instead.
|
|
92
|
+
started = lambda do
|
|
93
|
+
puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
|
|
94
|
+
puts "Press Ctrl-C to stop."
|
|
95
|
+
$stdout.flush # a log file isn't line-buffered
|
|
96
|
+
if open_browser
|
|
97
|
+
Thread.new do
|
|
98
|
+
sleep 0.8
|
|
99
|
+
open_url(url)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The event loops (GET /api/events) end once WEBrick leaves
|
|
105
|
+
# :Running: it joins every request thread before run returns.
|
|
106
|
+
Rackup::Handler::WEBrick.run(app, Host: host, Port: port, AccessLog: [], Logger: Log.new($stderr, WEBrick::Log::WARN),
|
|
107
|
+
StartCallback: started) do |server|
|
|
108
|
+
app.server_running = -> { server.status == :Running }
|
|
109
|
+
end
|
|
110
|
+
true
|
|
111
|
+
rescue Errno::EADDRINUSE
|
|
112
|
+
warn in_use_message(port)
|
|
113
|
+
false
|
|
114
|
+
ensure
|
|
115
|
+
hub&.stop
|
|
116
|
+
Samagotchi::Log.info(:web, "stop") if app
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.resolve_host(host)
|
|
120
|
+
host = (host || ENV.fetch("SAMAGOTCHI_WEB_HOST", DEFAULT_HOST)).to_s.strip
|
|
121
|
+
host = DEFAULT_HOST if host.empty?
|
|
122
|
+
return host if %w[127.0.0.1 ::1 localhost].include?(host)
|
|
123
|
+
|
|
124
|
+
Samagotchi::Log.warn(:web, "bind_forced", echo: "Web server only binds to 127.0.0.1 (got #{host}); forcing 127.0.0.1", host: host.to_s)
|
|
125
|
+
DEFAULT_HOST
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The page for +dir+: its project view (?dir=) when it is in a repo,
|
|
129
|
+
# the plain page (every session) outside one or with scope "all".
|
|
130
|
+
def self.scope_url(host, port, dir:, scope: "project")
|
|
131
|
+
base = "http://#{url_host(host)}:#{port}/"
|
|
132
|
+
return base if scope.to_s == "all" || ProjectScope.root_for(dir).nil?
|
|
133
|
+
|
|
134
|
+
# Slashes stay as they are (valid in a query): a URL people can read.
|
|
135
|
+
"#{base}?dir=#{URI.encode_www_form_component(dir).gsub("%2F", "/")}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.url_host(host)
|
|
139
|
+
host.include?(":") ? "[#{host}]" : host
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# What answers on the port: the /api/info hash of a chi web that knows
|
|
143
|
+
# ?dir, :free when nothing listens, :other for anything else (an older
|
|
144
|
+
# chi web, another program).
|
|
145
|
+
def self.probe(host, port, timeout: PROBE_TIMEOUT)
|
|
146
|
+
response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
|
|
147
|
+
probe_verdict(response.code.to_i, response.body)
|
|
148
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
|
|
149
|
+
:free
|
|
150
|
+
rescue StandardError
|
|
151
|
+
:other
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.probe_verdict(status, body)
|
|
155
|
+
info = status == 200 ? JSON.parse(body.to_s) : nil
|
|
156
|
+
info.is_a?(Hash) && info["app"] == "chi-web" && Array(info["features"]).include?("dir") ? info : :other
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
:other
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.in_use_message(port)
|
|
162
|
+
"Error: port #{port} is in use (an older chi web? restart it, or use --port)"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.resolve_port(port)
|
|
166
|
+
raw = port || ENV.fetch("SAMAGOTCHI_WEB_PORT", DEFAULT_PORT.to_s)
|
|
167
|
+
parsed = raw.to_s.to_i
|
|
168
|
+
parsed.positive? ? parsed : DEFAULT_PORT
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.open_url(url)
|
|
172
|
+
case RbConfig::CONFIG["host_os"]
|
|
173
|
+
when /darwin/ then system("open", url)
|
|
174
|
+
when /linux/ then system("xdg-open", url)
|
|
175
|
+
when /mswin|mingw/ then system("start", url)
|
|
176
|
+
else Samagotchi::Log.info(:web, "open_manually", echo: "Please open #{url} manually")
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
Samagotchi::Log.warn(:web, "open_failed", echo: "Failed to open browser: #{e.message} — please open #{url} manually", error: e.class.name)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "../session"
|
|
8
|
+
require_relative "../session_manager"
|
|
9
|
+
require_relative "../bridge_client"
|
|
10
|
+
require_relative "../log"
|
|
11
|
+
require_relative "session_summary"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
module Web
|
|
15
|
+
# chi web's one in-memory projection of the session list, fed by a cheap
|
|
16
|
+
# watcher over the state dir, pushed to every open tab (GET /api/events).
|
|
17
|
+
# Files stay the source of truth; workers and the Bridge don't know it.
|
|
18
|
+
#
|
|
19
|
+
# Every session-JSON writer goes tmp + rename, and rename-into-dir and
|
|
20
|
+
# unlink bump the parent dir's mtime, so one stat of the state dir says
|
|
21
|
+
# "some session file changed" and one stat of `<id>/` says "recap.json
|
|
22
|
+
# or bridge.json appeared, changed or went". Nothing in-place is
|
|
23
|
+
# watched: the pid file and owner.lock are read by the owner probes.
|
|
24
|
+
#
|
|
25
|
+
# #scan does one pass and is driven by the tick thread (#start) or by a
|
|
26
|
+
# spec. Scan, #touch, the projection and the deliveries all run under
|
|
27
|
+
# one Monitor, and a subscriber is added and its snapshot taken as one
|
|
28
|
+
# step under it, so no stale upsert can follow a fresh snapshot.
|
|
29
|
+
class SessionHub
|
|
30
|
+
SCAN_INTERVAL = 1.0
|
|
31
|
+
# The flock leaves no file trace when its holder dies, so owners are
|
|
32
|
+
# probed: every tick for the sessions the projection believes owned
|
|
33
|
+
# (a handful; OwnerLock.owner is open + LOCK_SH|LOCK_NB + close), and
|
|
34
|
+
# all sessions this often (a plain REPL that opened an old session
|
|
35
|
+
# without saving it has no file signal until its first save).
|
|
36
|
+
FULL_PROBE_INTERVAL = 10.0
|
|
37
|
+
|
|
38
|
+
# What a subscriber gets: `session` with {session: summary} for an
|
|
39
|
+
# upsert, `session_gone` with {id:} for a removal. seq is monotonic per
|
|
40
|
+
# hub, for the log's sake: there is no replay.
|
|
41
|
+
Event = Struct.new(:type, :seq, :data, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
# The projection's entry: the summary the page sees, the parsed
|
|
44
|
+
# (messages-less) Session it was built from, and the stamps that say
|
|
45
|
+
# whether anything changed: the file's [mtime, size], the session
|
|
46
|
+
# dir's mtime, and the live stamp {pid, kind, bridge_started_at} so
|
|
47
|
+
# a change of worker is a change even when `owner` reads the same.
|
|
48
|
+
Entry = Struct.new(:session, :summary, :stamp, :dir_stamp, :owner, :live, keyword_init: true)
|
|
49
|
+
|
|
50
|
+
# Returned by #subscribe.
|
|
51
|
+
class Subscription
|
|
52
|
+
def initialize(sink, hub)
|
|
53
|
+
@sink = sink
|
|
54
|
+
@hub = hub
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def call(event)
|
|
58
|
+
@sink.call(event)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def unsubscribe
|
|
62
|
+
@hub.unsubscribe(self)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param state_dir [String] the sessions dir (Session.default_state_dir)
|
|
67
|
+
# @param manager [#session_owner, #retention_sweep_if_due] SessionManager,
|
|
68
|
+
# or a stand-in
|
|
69
|
+
# @param now [#call] a monotonic clock in seconds (specs drive it)
|
|
70
|
+
def initialize(state_dir:, manager: SessionManager, now: nil, scan_interval: SCAN_INTERVAL,
|
|
71
|
+
full_probe_interval: FULL_PROBE_INTERVAL)
|
|
72
|
+
@state_dir = state_dir
|
|
73
|
+
@manager = manager
|
|
74
|
+
@now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
75
|
+
@scan_interval = scan_interval
|
|
76
|
+
@full_probe_interval = full_probe_interval
|
|
77
|
+
@monitor = Monitor.new
|
|
78
|
+
@sessions = {} # id → Entry
|
|
79
|
+
@subscribers = []
|
|
80
|
+
@seq = 0
|
|
81
|
+
@dir_mtime = nil # the state dir's mtime at the last glob
|
|
82
|
+
@root_cache = {} # folder → project root, for sessions saved before project_root
|
|
83
|
+
@next_full_probe = nil
|
|
84
|
+
@wakeup = Queue.new # stop wakes the tick thread out of its sleep
|
|
85
|
+
@thread = nil
|
|
86
|
+
@stopped = false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Spawn the tick thread (idempotent). Logs nothing: Server.start's
|
|
90
|
+
# start and stop lines are the web log's.
|
|
91
|
+
def start
|
|
92
|
+
return self if @thread&.alive?
|
|
93
|
+
|
|
94
|
+
@stopped = false
|
|
95
|
+
@thread = Thread.new { run_loop }
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# End the tick thread (idempotent); the event loops see #stopped?.
|
|
100
|
+
def stop
|
|
101
|
+
@stopped = true
|
|
102
|
+
@wakeup << :stop
|
|
103
|
+
thread = @thread
|
|
104
|
+
@thread = nil
|
|
105
|
+
thread&.join(@scan_interval + 1.0)
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stopped?
|
|
110
|
+
@stopped
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# One pass: what changed on disk since the last one, emitted. Every
|
|
114
|
+
# +full_probe_interval+ it also probes every session's owner and
|
|
115
|
+
# gives the retention sweep its chance (it used to run on
|
|
116
|
+
# GET /api/sessions, which the page no longer calls).
|
|
117
|
+
def scan
|
|
118
|
+
@monitor.synchronize do
|
|
119
|
+
full = full_probe_due?
|
|
120
|
+
scan_files
|
|
121
|
+
@sessions.each_key { |id| refresh(id, probe: full) }
|
|
122
|
+
sweep if full
|
|
123
|
+
end
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Rescan one session now (its file, its folder, its owner), for the
|
|
128
|
+
# page's own actions: no waiting for the next tick.
|
|
129
|
+
def touch(id)
|
|
130
|
+
@monitor.synchronize do
|
|
131
|
+
path = File.join(@state_dir, "#{id}#{Session::FILE_EXT}")
|
|
132
|
+
if File.file?(path)
|
|
133
|
+
@sessions[id] ||= Entry.new
|
|
134
|
+
refresh(id, probe: true)
|
|
135
|
+
elsif @sessions.key?(id)
|
|
136
|
+
drop(id)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The projection as the list route answers it: sorted, and only
|
|
143
|
+
# +project_root+'s sessions when one is given (nil: every session, as
|
|
144
|
+
# outside a repo).
|
|
145
|
+
# @return [Array<Hash>] summaries
|
|
146
|
+
def snapshot(project_root: nil, sort: "updated_at", order: "desc")
|
|
147
|
+
@monitor.synchronize do
|
|
148
|
+
entries = @sessions.values.reject { |e| e.summary.nil? }
|
|
149
|
+
entries = entries.select { |e| e.summary[:project_root] == project_root } if project_root
|
|
150
|
+
key = sort.to_s == "created_at" ? :created_at : :updated_at
|
|
151
|
+
sorted = entries.sort_by { |e| sort_time(e.summary[key]) }
|
|
152
|
+
sorted.reverse! unless order.to_s == "asc"
|
|
153
|
+
sorted.map(&:summary)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Register a sink (#call(event)). With +snapshot:+ the current
|
|
158
|
+
# projection comes back with the handle, taken in the same step, so
|
|
159
|
+
# every event after it is newer than it.
|
|
160
|
+
# @return [Subscription, (Subscription, Array<Hash>)]
|
|
161
|
+
def subscribe(sink, snapshot: false, project_root: nil)
|
|
162
|
+
@monitor.synchronize do
|
|
163
|
+
handle = Subscription.new(sink, self)
|
|
164
|
+
@subscribers << handle
|
|
165
|
+
return handle unless snapshot
|
|
166
|
+
|
|
167
|
+
[handle, self.snapshot(project_root: project_root)]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def unsubscribe(handle)
|
|
172
|
+
@monitor.synchronize { !@subscribers.delete(handle).nil? }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def run_loop
|
|
178
|
+
until @stopped
|
|
179
|
+
begin
|
|
180
|
+
scan
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
Log.warn(:web, "hub_scan_failed", error: e.class.name, msg: e.message)
|
|
183
|
+
end
|
|
184
|
+
@wakeup.pop(timeout: @scan_interval)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def full_probe_due?
|
|
189
|
+
now = @now.call
|
|
190
|
+
return false if @next_full_probe && now < @next_full_probe
|
|
191
|
+
|
|
192
|
+
@next_full_probe = now + @full_probe_interval
|
|
193
|
+
true
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def sweep
|
|
197
|
+
return unless @manager.respond_to?(:retention_sweep_if_due)
|
|
198
|
+
|
|
199
|
+
@manager.retention_sweep_if_due(state_dir: @state_dir)
|
|
200
|
+
rescue StandardError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The state dir's files: a new id is parsed and emitted, an id gone
|
|
205
|
+
# from disk is dropped, a changed file is re-parsed. Skipped when the
|
|
206
|
+
# dir's mtime says nothing was renamed in or unlinked since.
|
|
207
|
+
def scan_files
|
|
208
|
+
dir_stat = begin
|
|
209
|
+
File.stat(@state_dir)
|
|
210
|
+
rescue SystemCallError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
if dir_stat.nil?
|
|
214
|
+
@sessions.keys.each { |id| drop(id) }
|
|
215
|
+
@dir_mtime = nil
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
return if dir_stat.mtime == @dir_mtime
|
|
219
|
+
|
|
220
|
+
@dir_mtime = dir_stat.mtime
|
|
221
|
+
on_disk = Dir.glob(File.join(@state_dir, "*#{Session::FILE_EXT}")).to_h do |path|
|
|
222
|
+
[File.basename(path, Session::FILE_EXT), path]
|
|
223
|
+
end
|
|
224
|
+
(@sessions.keys - on_disk.keys).each { |id| drop(id) }
|
|
225
|
+
on_disk.each_key { |id| @sessions[id] ||= Entry.new }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Bring one entry up to date with its file, its folder and its owner;
|
|
229
|
+
# emit when the page would see a difference. The owner is probed when
|
|
230
|
+
# asked (+probe+), when the entry believes it has one (to see it go),
|
|
231
|
+
# and when its folder changed (a new owner.lock, a bridge.json).
|
|
232
|
+
def refresh(id, probe: false)
|
|
233
|
+
entry = @sessions[id]
|
|
234
|
+
path = File.join(@state_dir, "#{id}#{Session::FILE_EXT}")
|
|
235
|
+
stat = begin
|
|
236
|
+
File.stat(path)
|
|
237
|
+
rescue SystemCallError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
return drop(id) if stat.nil?
|
|
241
|
+
|
|
242
|
+
stamp = [stat.mtime, stat.size]
|
|
243
|
+
changed = false
|
|
244
|
+
if stamp != entry.stamp
|
|
245
|
+
session = Session.summary_from_file(path)
|
|
246
|
+
# Corrupt, or missing a field: not a session, as Session.list has it.
|
|
247
|
+
return drop(id) if session.nil?
|
|
248
|
+
|
|
249
|
+
# Looked up once per parse (a git call for a session saved before
|
|
250
|
+
# the field), never per tick.
|
|
251
|
+
session.project_root = session.project_root(cache: @root_cache)
|
|
252
|
+
entry.session = session
|
|
253
|
+
entry.stamp = stamp
|
|
254
|
+
changed = true
|
|
255
|
+
end
|
|
256
|
+
dir = Session.session_dir(id, state_dir: @state_dir)
|
|
257
|
+
dir_stamp = dir_mtime(dir)
|
|
258
|
+
if dir_stamp != entry.dir_stamp
|
|
259
|
+
entry.dir_stamp = dir_stamp
|
|
260
|
+
changed = true
|
|
261
|
+
end
|
|
262
|
+
owner = probe || changed || entry.owner ? owner_of(id) : entry.owner
|
|
263
|
+
live = live_stamp(owner, dir)
|
|
264
|
+
live_changed = live != entry.live
|
|
265
|
+
return unless changed || live_changed
|
|
266
|
+
|
|
267
|
+
entry.owner = owner
|
|
268
|
+
entry.live = live
|
|
269
|
+
summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir)
|
|
270
|
+
# A new worker (pid) or a new Bridge for the same session is an
|
|
271
|
+
# event for the page even when the summary reads the same: it
|
|
272
|
+
# reconnects its stream on it.
|
|
273
|
+
return if summary == entry.summary && !live_changed
|
|
274
|
+
|
|
275
|
+
entry.summary = summary
|
|
276
|
+
emit("session", session: summary)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def drop(id)
|
|
280
|
+
entry = @sessions.delete(id)
|
|
281
|
+
emit("session_gone", id: id) if entry&.summary
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def owner_of(id)
|
|
285
|
+
return nil unless @manager.respond_to?(:session_owner)
|
|
286
|
+
|
|
287
|
+
@manager.session_owner(id, state_dir: @state_dir)
|
|
288
|
+
rescue StandardError
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def dir_mtime(dir)
|
|
293
|
+
File.stat(dir).mtime
|
|
294
|
+
rescue SystemCallError
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# A change of worker is a change even when `owner` reads "worker"
|
|
299
|
+
# both times: the pid, or the Bridge's start time, tells them apart.
|
|
300
|
+
def live_stamp(owner, dir)
|
|
301
|
+
return nil unless owner
|
|
302
|
+
|
|
303
|
+
{ pid: owner["pid"], kind: owner["kind"], bridge_started_at: bridge_started_at(dir) }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def bridge_started_at(dir)
|
|
307
|
+
JSON.parse(File.read(File.join(dir, BridgeClient::SIDECAR_FILE)))["started_at"]
|
|
308
|
+
rescue StandardError
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def emit(type, data)
|
|
313
|
+
@seq += 1
|
|
314
|
+
event = Event.new(type: type, seq: @seq, data: data)
|
|
315
|
+
@subscribers.dup.each do |handle|
|
|
316
|
+
handle.call(event)
|
|
317
|
+
rescue StandardError => e
|
|
318
|
+
Log.warn(:web, "hub_subscriber_failed", error: e.class.name, msg: e.message)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def sort_time(value)
|
|
323
|
+
Time.iso8601(value.to_s)
|
|
324
|
+
rescue ArgumentError
|
|
325
|
+
Time.at(0)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../recap_store"
|
|
5
|
+
require_relative "../bridge_client"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Web
|
|
9
|
+
# A session as the page's cards and list see it: the saved session's
|
|
10
|
+
# fields with its live owner applied. Built the same way wherever a
|
|
11
|
+
# session is answered (GET /api/sessions, the session view, the session
|
|
12
|
+
# hub's events), so every path agrees on status, owner and bridge_up.
|
|
13
|
+
module SessionSummary
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param session [Session] a saved session (messages may be empty)
|
|
17
|
+
# @param owner [Hash, nil] SessionManager.session_owner's answer; its
|
|
18
|
+
# kind is shown as `owner`
|
|
19
|
+
# @param session_dir [String] the session's folder (recap.json, bridge.json)
|
|
20
|
+
# @param status [String] the turn state to show; by default
|
|
21
|
+
# .displayed_status of the session and its owner
|
|
22
|
+
# @param root_cache [Hash, nil] ProjectScope.root_for's cache, shared
|
|
23
|
+
# across one listing (a session saved before project_root existed
|
|
24
|
+
# looks its project up)
|
|
25
|
+
# @return [Hash] symbol keys
|
|
26
|
+
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
|
|
27
|
+
used = session.respond_to?(:used_memory_names) ? Array(session.used_memory_names) : []
|
|
28
|
+
{
|
|
29
|
+
id: session.id,
|
|
30
|
+
status: status,
|
|
31
|
+
mode: session.mode,
|
|
32
|
+
model_name: session.model_name,
|
|
33
|
+
working_directory: session.working_directory,
|
|
34
|
+
created_at: session.created_at,
|
|
35
|
+
updated_at: session.updated_at,
|
|
36
|
+
last_prompt: session.last_prompt,
|
|
37
|
+
short_id: session.id.to_s[0, 8],
|
|
38
|
+
test_run: !!session.test_run,
|
|
39
|
+
used_memory_names: used,
|
|
40
|
+
# The session's --memory and --mute lists (info-bar tooltip).
|
|
41
|
+
preloaded_memory_names: session.respond_to?(:preloaded_memory_names) ? Array(session.preloaded_memory_names) : [],
|
|
42
|
+
muted_memory_names: session.respond_to?(:muted_memory_names) ? Array(session.muted_memory_names) : [],
|
|
43
|
+
# The session that delegated this one (the `delegate` tool), else nil.
|
|
44
|
+
parent_id: session.respond_to?(:parent_id) ? session.parent_id : nil,
|
|
45
|
+
first_preview: first_preview_for(session),
|
|
46
|
+
owner: owner&.fetch("kind", nil),
|
|
47
|
+
# The saved recap's first sentence, for the session card.
|
|
48
|
+
recap: RecapStore.preview(session_dir),
|
|
49
|
+
project_root: session.project_root(cache: root_cache),
|
|
50
|
+
# A worker is reachable: its Bridge sidecar is there and the
|
|
51
|
+
# owner lock is held. A sidecar a dead worker left is not up.
|
|
52
|
+
bridge_up: bridge_up?(session_dir, owner)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# status is turn state (idle/running). The live worker's snapshot is the
|
|
57
|
+
# truth; on disk, a "running" with no live owner was left by a worker
|
|
58
|
+
# that died mid-turn.
|
|
59
|
+
def displayed_status(session, snapshot = nil, owner:)
|
|
60
|
+
return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
|
|
61
|
+
return session.status unless session.status == Session::STATUS_RUNNING
|
|
62
|
+
|
|
63
|
+
owner ? session.status : Session::STATUS_IDLE
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] the sidecar file is there and the session has a
|
|
67
|
+
# live owner (the file alone says nothing: the worker may be gone)
|
|
68
|
+
def bridge_up?(session_dir, owner)
|
|
69
|
+
!owner.nil? && File.file?(File.join(session_dir, BridgeClient::SIDECAR_FILE))
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def first_preview_for(session)
|
|
75
|
+
raw = session.first_preview || session.last_prompt || ""
|
|
76
|
+
norm = raw.to_s.gsub(/\s+/, " ").strip
|
|
77
|
+
return "" if norm.empty?
|
|
78
|
+
|
|
79
|
+
norm.length > 80 ? "#{norm[0, 80]}…" : norm
|
|
80
|
+
rescue StandardError
|
|
81
|
+
""
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|