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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../token_usage"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module LLM
|
|
7
|
+
# A turn's token counts, never nil: what the server reported (:server),
|
|
8
|
+
# else a chars/4 estimate (:estimate), else zeros (:none).
|
|
9
|
+
Usage = Data.define(:prompt_tokens, :completion_tokens, :source) do
|
|
10
|
+
# @return [Usage, nil] nil when the payload carries no counts
|
|
11
|
+
def self.from_payload(payload)
|
|
12
|
+
counts = TokenUsage.from_payload(payload)
|
|
13
|
+
return nil unless counts
|
|
14
|
+
|
|
15
|
+
new(prompt_tokens: counts[:prompt_tokens].to_i, completion_tokens: counts[:completion_tokens].to_i,
|
|
16
|
+
source: :server)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# +extra_prompt_tokens+: what the prompt holds besides text (images).
|
|
20
|
+
def self.estimate(prompt_text:, completion_text:, extra_prompt_tokens: 0)
|
|
21
|
+
new(prompt_tokens: TokenUsage.estimate(prompt_text.to_s) + extra_prompt_tokens.to_i,
|
|
22
|
+
completion_tokens: TokenUsage.estimate(completion_text.to_s),
|
|
23
|
+
source: :estimate)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
|
|
27
|
+
|
|
28
|
+
def total_tokens = prompt_tokens + completion_tokens
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Builds a turn's Usage from its stream events, the way SessionMetrics
|
|
32
|
+
# counts them: server counts are cumulative per request, so each
|
|
33
|
+
# generation keeps its highest; the prompt grows across a turn's
|
|
34
|
+
# generations, so the last generation's prompt counts, and completions
|
|
35
|
+
# add up. Without server counts, the streamed text is estimated.
|
|
36
|
+
class UsageCollector
|
|
37
|
+
def initialize
|
|
38
|
+
@generations = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def observe(event)
|
|
42
|
+
case event[:type]
|
|
43
|
+
when :generation_started
|
|
44
|
+
@generations << { prompt: 0, completion: 0, server: false, text: +"" }
|
|
45
|
+
when :generation_chunk
|
|
46
|
+
@generations << { prompt: 0, completion: 0, server: false, text: +"" } if @generations.empty?
|
|
47
|
+
record(@generations.last, event)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param prompt_text [String, nil] what an estimate counts as the prompt
|
|
52
|
+
# @param extra_prompt_tokens [Integer] estimated non-text prompt tokens
|
|
53
|
+
def usage(prompt_text: nil, extra_prompt_tokens: 0)
|
|
54
|
+
server = @generations.select { |generation| generation[:server] }
|
|
55
|
+
unless server.empty?
|
|
56
|
+
return Usage.new(prompt_tokens: server.last[:prompt], completion_tokens: server.sum { |g| g[:completion] },
|
|
57
|
+
source: :server)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
text = @generations.sum("") { |generation| generation[:text] }
|
|
61
|
+
return Usage.none if text.empty?
|
|
62
|
+
|
|
63
|
+
Usage.estimate(prompt_text: prompt_text, completion_text: text, extra_prompt_tokens: extra_prompt_tokens)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def record(generation, event)
|
|
69
|
+
counts = Usage.from_payload(event[:payload])
|
|
70
|
+
if counts
|
|
71
|
+
generation[:server] = true
|
|
72
|
+
generation[:prompt] = [generation[:prompt], counts.prompt_tokens].max
|
|
73
|
+
generation[:completion] = [generation[:completion], counts.completion_tokens].max
|
|
74
|
+
end
|
|
75
|
+
generation[:text] << event[:content].to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "log_line"
|
|
5
|
+
require_relative "debug_log"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
autoload :Config, File.expand_path("config", __dir__)
|
|
9
|
+
autoload :LogPath, File.expand_path("log_path", __dir__)
|
|
10
|
+
|
|
11
|
+
# The one logging facade: tagged, levelled records in the debug log
|
|
12
|
+
# (LogPath, format in LogLine), one process-wide sink.
|
|
13
|
+
#
|
|
14
|
+
# Log.info(:worker, "idle_exit", idle_s: 1800)
|
|
15
|
+
# Log.warn(:hooks, "hook_failed", echo: "[samagotchi:hooks] …")
|
|
16
|
+
# Log.debug(:model, "response", payload: text, model: "qwen")
|
|
17
|
+
#
|
|
18
|
+
# echo: is for what used to be a plain `warn`: that text still goes to
|
|
19
|
+
# stderr unchanged (whatever the level; not in a worker, see configure)
|
|
20
|
+
# and becomes the record's msg= field.
|
|
21
|
+
#
|
|
22
|
+
# Unconfigured, the first record resolves the file and level from Config
|
|
23
|
+
# (once, until Config.reload!). While that runs (Config can warn itself)
|
|
24
|
+
# records only echo. Nothing here raises into the caller: a record that
|
|
25
|
+
# can't be formatted is dropped, an unwritable file pauses (DebugLog).
|
|
26
|
+
module Log
|
|
27
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
28
|
+
DEFAULT_LEVEL = :info
|
|
29
|
+
MAX_PAYLOAD_BYTES = 64 * 1024
|
|
30
|
+
SID_LENGTH = 8
|
|
31
|
+
BACKTRACE_FRAMES = 20
|
|
32
|
+
# A field named like a credential never shows its value. Whole name
|
|
33
|
+
# segments, so counts like prompt_tokens stay readable.
|
|
34
|
+
SECRET_KEY = /(?:\A|_)(?:key|api_?key|token|secret|authorization|auth|password|passwd|cookie)(?:\z|_)/
|
|
35
|
+
URL = %r{\A[a-z][a-z0-9+.-]*://}i
|
|
36
|
+
|
|
37
|
+
@monitor = Monitor.new
|
|
38
|
+
@options = {}
|
|
39
|
+
@state = nil
|
|
40
|
+
@resolving = false
|
|
41
|
+
@session_id = nil
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# @param path [String, nil, :auto] the file; :auto → LogPath.resolve
|
|
45
|
+
# @param level [Symbol, String, nil] nil → log.level
|
|
46
|
+
# @param stderr [Boolean] false in a worker: echo: text goes nowhere
|
|
47
|
+
# (its stderr is /dev/null anyway), only to the file
|
|
48
|
+
# @param mirror [Boolean] -v: every record written also goes to stderr
|
|
49
|
+
def configure(path: :auto, level: nil, stderr: true, mirror: false)
|
|
50
|
+
@monitor.synchronize do
|
|
51
|
+
close_state
|
|
52
|
+
@options = { path: path, level: level, stderr: stderr, mirror: mirror }
|
|
53
|
+
end
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Back to unconfigured (specs: before each example).
|
|
58
|
+
def reset!
|
|
59
|
+
@monitor.synchronize do
|
|
60
|
+
close_state
|
|
61
|
+
@options = {}
|
|
62
|
+
@session_id = nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Config changed (Config.reload!): resolve :auto parts again.
|
|
67
|
+
def invalidate!
|
|
68
|
+
@monitor.synchronize { close_state }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The session this process works for (a worker, the REPL); records
|
|
72
|
+
# carry its first SID_LENGTH chars unless they pass their own sid:.
|
|
73
|
+
attr_accessor :session_id
|
|
74
|
+
|
|
75
|
+
def debug(tag, event, **kw) = log(:debug, tag, event, **kw)
|
|
76
|
+
def info(tag, event, **kw) = log(:info, tag, event, **kw)
|
|
77
|
+
def warn(tag, event, **kw) = log(:warn, tag, event, **kw)
|
|
78
|
+
def error(tag, event, **kw) = log(:error, tag, event, **kw)
|
|
79
|
+
|
|
80
|
+
# ERROR with the exception and its first BACKTRACE_FRAMES frames as
|
|
81
|
+
# the payload (crashes of threads and workers).
|
|
82
|
+
def exception(tag, event, error, echo: nil, **fields)
|
|
83
|
+
frames = Array(error.backtrace).first(BACKTRACE_FRAMES)
|
|
84
|
+
log(:error, tag, event, echo: echo, payload: frames.join("\n"),
|
|
85
|
+
error: error.class.name, msg: error.message.to_s[0, 500], **fields)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether a record at this level reaches the file or stderr (to skip
|
|
89
|
+
# building a big payload for nothing).
|
|
90
|
+
def level?(level)
|
|
91
|
+
state = resolved_state
|
|
92
|
+
state ? LEVELS.fetch(level.to_sym) >= state[:level] : false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def path
|
|
96
|
+
resolved_state&.dig(:writer)&.path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def log(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
|
|
100
|
+
options = @options
|
|
101
|
+
echo_to_stderr(echo) if echo && options.fetch(:stderr, true)
|
|
102
|
+
state = resolved_state
|
|
103
|
+
return nil unless state && LEVELS.fetch(level) >= state[:level]
|
|
104
|
+
|
|
105
|
+
fields = { msg: echo }.merge(fields) if echo && !fields.key?(:msg)
|
|
106
|
+
text = build(level, tag, event, payload: payload, sid: sid || @session_id, fields: fields)
|
|
107
|
+
return nil unless text
|
|
108
|
+
|
|
109
|
+
state[:writer].write(text)
|
|
110
|
+
mirror(text) if state[:mirror] && !echo
|
|
111
|
+
text
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def build(level, tag, event, payload:, sid:, fields:)
|
|
119
|
+
tag = tag.to_s
|
|
120
|
+
event = event.to_s
|
|
121
|
+
raise ArgumentError, "unknown log tag #{tag}" unless LogLine::TAGS.include?(tag)
|
|
122
|
+
raise ArgumentError, "bad log event #{event}" unless event.match?(LogLine::SLUG)
|
|
123
|
+
|
|
124
|
+
fields = fields.each_with_object({}) do |(key, value), out|
|
|
125
|
+
key = key.to_s
|
|
126
|
+
next if value.nil? || !key.match?(LogLine::KEY)
|
|
127
|
+
|
|
128
|
+
out[key] = safe_value(key, value)
|
|
129
|
+
end
|
|
130
|
+
payload = payload.to_s if payload
|
|
131
|
+
if payload && payload.bytesize > MAX_PAYLOAD_BYTES
|
|
132
|
+
fields["truncated"] = payload.bytesize - MAX_PAYLOAD_BYTES
|
|
133
|
+
payload = payload.byteslice(0, MAX_PAYLOAD_BYTES)
|
|
134
|
+
end
|
|
135
|
+
LogLine.format(LogLine::Record.new(
|
|
136
|
+
ts: Time.now, level: level.to_s.upcase, tag: tag, pid: Process.pid,
|
|
137
|
+
sid: sid && sid.to_s[0, SID_LENGTH], event: event, fields: fields, payload: payload
|
|
138
|
+
))
|
|
139
|
+
rescue StandardError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def safe_value(key, value)
|
|
144
|
+
return "[redacted]" if key.match?(SECRET_KEY)
|
|
145
|
+
|
|
146
|
+
value = value.is_a?(Float) ? value.round(3) : value
|
|
147
|
+
value.is_a?(String) && value.match?(URL) ? safe_url(value) : value
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# No userinfo, no query or fragment (keys ride there sometimes).
|
|
151
|
+
def safe_url(url)
|
|
152
|
+
url.sub(%r{\A([a-z][a-z0-9+.-]*://)[^/?#@]*@}i, "\\1").sub(/[?#].*\z/m, "")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def echo_to_stderr(text)
|
|
156
|
+
$stderr.puts(text)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def mirror(text)
|
|
162
|
+
$stderr.write(text)
|
|
163
|
+
rescue StandardError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def resolved_state
|
|
168
|
+
state = @state
|
|
169
|
+
return state if state
|
|
170
|
+
|
|
171
|
+
@monitor.synchronize do
|
|
172
|
+
return @state if @state
|
|
173
|
+
# Config.get below may log a warning: that one only echoes.
|
|
174
|
+
return nil if @resolving
|
|
175
|
+
|
|
176
|
+
@resolving = true
|
|
177
|
+
begin
|
|
178
|
+
@state = build_state
|
|
179
|
+
ensure
|
|
180
|
+
@resolving = false
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_state
|
|
186
|
+
options = @options
|
|
187
|
+
path = options.fetch(:path, :auto)
|
|
188
|
+
path = (LogPath.resolve rescue nil) if path == :auto
|
|
189
|
+
level = options[:level] || (Config.get("log.level") rescue nil)
|
|
190
|
+
level = LEVELS.key?(level.to_s.downcase.to_sym) ? level.to_s.downcase.to_sym : DEFAULT_LEVEL
|
|
191
|
+
{ writer: DebugLog.new(path: path), level: LEVELS.fetch(level), mirror: options[:mirror] ? true : false }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def close_state
|
|
195
|
+
@state&.dig(:writer)&.close
|
|
196
|
+
@state = nil
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# The debug log's line format: the one contract between the writer (Log)
|
|
8
|
+
# and every reader (the web's later log view, `chi log`, grep and awk).
|
|
9
|
+
#
|
|
10
|
+
# <ts> <LEVEL> <tag> pid=<n> [sid=<8>] <event> [k=v ...]
|
|
11
|
+
# <payload line>
|
|
12
|
+
#
|
|
13
|
+
# ts is UTC ISO8601 with milliseconds, LEVEL is padded to 5, tag and event
|
|
14
|
+
# are slugs. A value is bare when it has no space, quote or `=`, else a
|
|
15
|
+
# JSON string, so a newline in a value never splits a record. Payload lines
|
|
16
|
+
# (debug dumps) are indented by four spaces and belong to the record above.
|
|
17
|
+
module LogLine
|
|
18
|
+
LEVELS = %w[DEBUG INFO WARN ERROR].freeze
|
|
19
|
+
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model].freeze
|
|
20
|
+
INDENT = " "
|
|
21
|
+
|
|
22
|
+
Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
SLUG = /\A[a-z0-9_.-]+\z/
|
|
25
|
+
KEY = /\A[a-z0-9_]+\z/
|
|
26
|
+
BARE = /\A[^"=\p{Z}\p{Cc}\p{Cf}]+\z/
|
|
27
|
+
HEADER = /\A(?<ts>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z) (?<level>DEBUG|INFO|WARN|ERROR) +(?<tag>[a-z]+) pid=(?<pid>\d+)(?: sid=(?<sid>[^\s"=]+))? (?<event>[a-z0-9_.-]+)(?<rest>.*)\z/
|
|
28
|
+
FIELD = /\G (?<key>[a-z0-9_]+)=(?:"(?<quoted>(?:[^"\\]|\\.)*)"|(?<bare>[^\s"=]+))/
|
|
29
|
+
# C1 controls and the line/paragraph separators JSON.generate leaves as is.
|
|
30
|
+
EXTRA_ESCAPES = /[\u007f-\u009f\u2028\u2029]/
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# The record as one string: header, payload lines, trailing newline.
|
|
35
|
+
def format(record)
|
|
36
|
+
head = [format_ts(record.ts), record.level.to_s.upcase.ljust(5), record.tag.to_s, "pid=#{record.pid}"]
|
|
37
|
+
head << "sid=#{record.sid}" if record.sid && !record.sid.to_s.empty?
|
|
38
|
+
head << record.event.to_s
|
|
39
|
+
line = +head.join(" ")
|
|
40
|
+
(record.fields || {}).each { |key, value| line << " #{key}=#{format_value(value)}" unless value.nil? }
|
|
41
|
+
line << "\n"
|
|
42
|
+
payload = record.payload
|
|
43
|
+
line << format_payload(payload) if payload && !payload.to_s.empty?
|
|
44
|
+
line
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_ts(time)
|
|
48
|
+
return time if time.is_a?(String)
|
|
49
|
+
|
|
50
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def format_value(value)
|
|
54
|
+
text = clean(value.to_s)
|
|
55
|
+
return text if text.match?(BARE)
|
|
56
|
+
|
|
57
|
+
JSON.generate(text).gsub(EXTRA_ESCAPES) { |c| Kernel.format("\\u%04x", c.ord) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Every payload line indented; control characters (ANSI colours from a
|
|
61
|
+
# tool, a stray \r) shown escaped so the log can be cat'ed safely.
|
|
62
|
+
def format_payload(payload)
|
|
63
|
+
clean(payload.to_s).split("\n", -1).map { |line| "#{INDENT}#{escape_controls(line.chomp("\r"))}\n" }.join
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def escape_controls(text)
|
|
67
|
+
text.gsub(/[\p{Cc}&&[^\t]]|#{EXTRA_ESCAPES}/o) { |c| c == "\e" ? "\\e" : Kernel.format("\\u%04x", c.ord) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Valid UTF-8 whatever the source (binary tool output included).
|
|
71
|
+
def clean(text)
|
|
72
|
+
text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
73
|
+
text.valid_encoding? ? text : text.scrub("?")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One header line → Record (payload nil), or nil when it isn't one.
|
|
77
|
+
def parse(line)
|
|
78
|
+
match = HEADER.match(line.to_s.chomp)
|
|
79
|
+
return nil unless match
|
|
80
|
+
|
|
81
|
+
fields = parse_fields(match[:rest])
|
|
82
|
+
return nil unless fields
|
|
83
|
+
|
|
84
|
+
Record.new(ts: match[:ts], level: match[:level], tag: match[:tag], pid: Integer(match[:pid]),
|
|
85
|
+
sid: match[:sid], event: match[:event], fields: fields, payload: nil)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_fields(rest)
|
|
89
|
+
fields = {}
|
|
90
|
+
pos = 0
|
|
91
|
+
while pos < rest.length
|
|
92
|
+
m = FIELD.match(rest, pos)
|
|
93
|
+
return nil unless m && m.begin(0) == pos
|
|
94
|
+
|
|
95
|
+
fields[m[:key]] = m[:quoted] ? JSON.parse("[\"#{m[:quoted]}\"]").first : m[:bare]
|
|
96
|
+
pos = m.end(0)
|
|
97
|
+
end
|
|
98
|
+
fields
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def payload_line?(line)
|
|
104
|
+
line.start_with?(INDENT)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Yields each Record of a log (payload lines joined onto theirs). Lines
|
|
108
|
+
# that are neither (an older format, a torn write) go to on_invalid.
|
|
109
|
+
def each_record(io, on_invalid: nil)
|
|
110
|
+
return enum_for(:each_record, io, on_invalid: on_invalid) unless block_given?
|
|
111
|
+
|
|
112
|
+
current = nil
|
|
113
|
+
io.each_line do |raw|
|
|
114
|
+
line = clean(raw).chomp
|
|
115
|
+
if current && payload_line?(line)
|
|
116
|
+
text = line.delete_prefix(INDENT)
|
|
117
|
+
current.payload = current.payload ? "#{current.payload}\n#{text}" : text
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
yield current if current
|
|
121
|
+
current = parse(line)
|
|
122
|
+
on_invalid&.call(line) if current.nil? && !line.empty?
|
|
123
|
+
end
|
|
124
|
+
yield current if current
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Where the debug log (DebugLog) goes, for the REPL, the attached terminal
|
|
7
|
+
# and the background worker alike: nil when log.disable is set, else log.file
|
|
8
|
+
# (~ and relative paths expanded against cwd), else
|
|
9
|
+
# $XDG_STATE_HOME/samagotchi/samagotchi.log (~/.local/state/... by default),
|
|
10
|
+
# next to the sessions and prompt history. Never inside the gem or checkout.
|
|
11
|
+
module LogPath
|
|
12
|
+
FILENAME = "samagotchi.log"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def resolve(env: ENV)
|
|
17
|
+
return nil if Config.get("log.disable")
|
|
18
|
+
|
|
19
|
+
configured = Config.get("log.file").to_s.strip
|
|
20
|
+
return File.expand_path(configured) unless configured.empty?
|
|
21
|
+
|
|
22
|
+
default_path(env: env)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_path(env: ENV)
|
|
26
|
+
xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
|
|
27
|
+
base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
28
|
+
File.join(base, "samagotchi", FILENAME)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The event trail: a SessionObserver subscriber (next to SessionMetrics)
|
|
7
|
+
# that writes what happened in a session, in order, as `turn` records.
|
|
8
|
+
# Both loops (native and chat) emit the same vocabulary, so every host
|
|
9
|
+
# leaves the same trail. Event names are the observer's types.
|
|
10
|
+
#
|
|
11
|
+
# INFO carries sizes and timings, never the text of a prompt, answer or
|
|
12
|
+
# tool output (DEBUG dumps are KernelLoop's). It runs under the observer's
|
|
13
|
+
# lock: it only formats and appends (Log never blocks on rotation).
|
|
14
|
+
class LogSubscriber
|
|
15
|
+
TAG = :turn
|
|
16
|
+
# One line each, with a few of their own fields (never text).
|
|
17
|
+
ANNOUNCED = {
|
|
18
|
+
turn_enqueued: %i[enqueued_id client_id],
|
|
19
|
+
command_queued: %i[command_id client_id],
|
|
20
|
+
command_ran: %i[command_id client_id status],
|
|
21
|
+
input_merged: %i[count],
|
|
22
|
+
prompt_restored: [],
|
|
23
|
+
continue_offered: %i[no_interrupt],
|
|
24
|
+
continue_resolved: [],
|
|
25
|
+
context_added: %i[note_id source],
|
|
26
|
+
reminder_injected: [],
|
|
27
|
+
question_requested: [],
|
|
28
|
+
question_answered: %i[id],
|
|
29
|
+
question_cancelled: %i[id reason],
|
|
30
|
+
pending_input_merged: %i[iteration count],
|
|
31
|
+
generation_cancelled: %i[iteration]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# @param session_id [#call] the session the events are about (the
|
|
35
|
+
# Engine's current one), as the records' sid
|
|
36
|
+
def initialize(session_id: -> {}, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
37
|
+
@session_id = session_id
|
|
38
|
+
@clock = clock
|
|
39
|
+
@turn_started_at = nil
|
|
40
|
+
@generation_started_at = {}
|
|
41
|
+
@tool_started_at = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(event)
|
|
45
|
+
type = event[:type]&.to_sym
|
|
46
|
+
return unless type
|
|
47
|
+
|
|
48
|
+
if respond_to?(handler = :"on_#{type}", true)
|
|
49
|
+
send(handler, event)
|
|
50
|
+
elsif ANNOUNCED.key?(type)
|
|
51
|
+
log(:info, type, **event.slice(*ANNOUNCED[type]), **origin(event), **items(event))
|
|
52
|
+
end
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def on_turn_started(event)
|
|
60
|
+
@turn_started_at = @clock.call
|
|
61
|
+
@generation_started_at.clear
|
|
62
|
+
@tool_started_at.clear
|
|
63
|
+
log(:info, :turn_started, session: event[:session_id], prompt_chars: event[:prompt].to_s.length,
|
|
64
|
+
continue: event[:continue] || nil, **items(event), **origin(event))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def on_turn_completed(event)
|
|
68
|
+
summary = event[:turn_summary] || {}
|
|
69
|
+
log(:info, :turn_completed, ms: since(@turn_started_at), result_chars: summary[:output].to_s.length,
|
|
70
|
+
tools: Array(summary[:tool_activity]).size,
|
|
71
|
+
exhausted: summary[:exhausted] || nil, **origin(event))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def on_turn_canceled(event)
|
|
75
|
+
log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def on_turn_failed(event)
|
|
79
|
+
log(:warn, :turn_failed, ms: since(@turn_started_at), error: event[:error_class], error_kind: event[:error_kind],
|
|
80
|
+
host: event[:host], retryable: event[:retryable],
|
|
81
|
+
msg: (event[:summary] || event[:message]).to_s[0, 300], **origin(event))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def on_generation_started(event)
|
|
85
|
+
@generation_started_at[event[:iteration]] = @clock.call
|
|
86
|
+
log(:debug, :generation_started, iteration: event[:iteration], profile: event[:profile],
|
|
87
|
+
context_window: event[:context_window_tokens])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def on_generation_completed(event)
|
|
91
|
+
log(:info, :generation_completed, iteration: event[:iteration],
|
|
92
|
+
ms: since(@generation_started_at.delete(event[:iteration])),
|
|
93
|
+
served_model: event[:served_model], requested_model: event[:requested_model],
|
|
94
|
+
content_length: event[:content_length],
|
|
95
|
+
thinking_chars: event[:thinking_chars])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def on_generation_retrying(event)
|
|
99
|
+
log(:warn, :generation_retrying, iteration: event[:iteration], attempt: event[:attempt],
|
|
100
|
+
max_retries: event[:max_retries], delay_s: event[:next_delay],
|
|
101
|
+
error: event[:error_class], msg: event[:error_message].to_s[0, 300])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def on_tool_call_started(event)
|
|
105
|
+
@tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def on_tool_call_completed(event)
|
|
109
|
+
# A tool's output can be any bytes (invalid UTF-8 would fail the match).
|
|
110
|
+
output = event[:output].to_s.scrub
|
|
111
|
+
log(:info, :tool_call_completed, iteration: event[:iteration], tool: event[:tool],
|
|
112
|
+
ms: since(@tool_started_at.delete([event[:iteration], event[:call_index]])),
|
|
113
|
+
output_chars: output.length, truncated: event[:output_truncated] || nil,
|
|
114
|
+
error: tool_error?(output) || nil)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def on_guardrail_warning(event)
|
|
118
|
+
log(:warn, :guardrail_warning, label: event[:label], msg: event[:message].to_s[0, 300])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A hook's notice to the user: who said it and what (its text is the
|
|
122
|
+
# hook's, not the user's or the model's).
|
|
123
|
+
def on_hook_notice(event)
|
|
124
|
+
level = event[:level].to_s == "warn" ? :warn : :info
|
|
125
|
+
log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A card shown to the user (Engine#show_card): whose, which, and its
|
|
129
|
+
# title (the body is the plugin's text; not logged).
|
|
130
|
+
def on_card(event)
|
|
131
|
+
log(:info, :card, source: event[:source], id: event[:id], actions: Array(event[:actions]).size,
|
|
132
|
+
msg: event[:title].to_s[0, 300])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def on_recap_ready(event)
|
|
136
|
+
log(:info, :recap_ready, chars: event[:recap].to_s.length, generation: event[:generation], covered: event[:covered])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# "[read] Error: …" (the dispatch failed) or "[read]\nError: …" (the
|
|
140
|
+
# tool said so), and an unknown tool's bare "Error: …".
|
|
141
|
+
def tool_error?(output)
|
|
142
|
+
output.match?(/\A(?:\[[^\]\n]*\]\s*)?Error:/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def origin(event)
|
|
146
|
+
client = event.dig(:origin, :client_id) if event[:origin].is_a?(Hash)
|
|
147
|
+
client ? { client_id: client } : {}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def items(event)
|
|
151
|
+
images = Array(event[:images]).size
|
|
152
|
+
images.positive? ? { images: images } : {}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def since(started)
|
|
156
|
+
started ? ((@clock.call - started) * 1000).round : nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def log(level, event, **fields)
|
|
160
|
+
Log.public_send(level, TAG, event, sid: @session_id.call, **fields)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|