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,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# Shared background poller for all Engine-owned idle subsystems
|
|
8
|
+
# (currently: IdleReminders and the optional IdleRecap).
|
|
9
|
+
#
|
|
10
|
+
# Previously each subsystem ran its own 0.5s polling thread against the
|
|
11
|
+
# Engine's `last_activity_at` / `activity_seq` / `turn_running?` seam.
|
|
12
|
+
# Now a single thread steps every registered job in turn, so the whole
|
|
13
|
+
# idle layer polls the Engine seam from one loop.
|
|
14
|
+
#
|
|
15
|
+
# Jobs must implement `tick`. Each job keeps its own eligibility guard
|
|
16
|
+
# (e.g. `should_fire?` / `should_check?`, including the `turn_running?`
|
|
17
|
+
# check) and its own internal state (generation ids, latches). A job that
|
|
18
|
+
# raises is logged and isolated — the remaining jobs keep polling.
|
|
19
|
+
class IdleScheduler
|
|
20
|
+
POLL_INTERVAL_SECONDS = 0.5
|
|
21
|
+
|
|
22
|
+
attr_reader :engine, :jobs
|
|
23
|
+
|
|
24
|
+
def initialize(engine:, jobs: [])
|
|
25
|
+
raise ArgumentError, "IdleScheduler requires an engine" unless engine
|
|
26
|
+
|
|
27
|
+
@engine = engine
|
|
28
|
+
@jobs = jobs.compact
|
|
29
|
+
@thread = nil
|
|
30
|
+
@stopped = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Spawn the polling thread (idempotent; no-op when there are no jobs).
|
|
34
|
+
def start
|
|
35
|
+
return self if running? || @jobs.empty?
|
|
36
|
+
|
|
37
|
+
@thread = Thread.new { run_loop }
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def running?
|
|
42
|
+
!@thread.nil? && @thread.alive? && !@stopped
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stop the polling thread (idempotent).
|
|
46
|
+
def stop
|
|
47
|
+
@stopped = true
|
|
48
|
+
@thread&.kill
|
|
49
|
+
@thread = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One step across all jobs. Public so specs can drive it deterministically.
|
|
53
|
+
def tick
|
|
54
|
+
return if @stopped
|
|
55
|
+
|
|
56
|
+
@jobs.each do |job|
|
|
57
|
+
job.tick
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Log.warn(:idle, "tick_failed", echo: "[IdleScheduler] #{job.class} tick failed: #{e.class}: #{e.message}", job: job.class.name, error: e.class.name)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def run_loop
|
|
66
|
+
loop do
|
|
67
|
+
break if @stopped
|
|
68
|
+
|
|
69
|
+
tick
|
|
70
|
+
sleep(POLL_INTERVAL_SECONDS)
|
|
71
|
+
end
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Log.exception(:idle, "scheduler_crashed", e, echo: "[IdleScheduler] scheduler thread crashed: #{e.class}: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require_relative "config"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# Reads an image's format and size from its first bytes, in pure Ruby.
|
|
12
|
+
# Formats a model can take (png, jpeg, gif, webp) come with their size;
|
|
13
|
+
# bmp comes with its size too, tiff and heic without (they are converted
|
|
14
|
+
# before they are sent anyway).
|
|
15
|
+
module ImageHeader
|
|
16
|
+
Info = Data.define(:format, :width, :height)
|
|
17
|
+
|
|
18
|
+
SENDABLE = %i[png jpeg gif webp].freeze
|
|
19
|
+
CONVERTIBLE = %i[bmp tiff heic].freeze
|
|
20
|
+
HEIC_BRANDS = %w[heic heix hevc hevx mif1 msf1].freeze
|
|
21
|
+
|
|
22
|
+
# The format by magic bytes, or nil for anything else.
|
|
23
|
+
def self.format_of(bytes)
|
|
24
|
+
b = bytes.to_s.b
|
|
25
|
+
return :png if b.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
26
|
+
return :jpeg if b.start_with?("\xFF\xD8\xFF".b)
|
|
27
|
+
return :gif if b.start_with?("GIF87a", "GIF89a")
|
|
28
|
+
return :webp if b.start_with?("RIFF") && b[8, 4] == "WEBP"
|
|
29
|
+
return :bmp if b.start_with?("BM") && b.bytesize >= 26
|
|
30
|
+
return :tiff if b.start_with?("II*\x00".b, "MM\x00*".b)
|
|
31
|
+
return :heic if b[4, 4] == "ftyp" && HEIC_BRANDS.include?(b[8, 4])
|
|
32
|
+
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# An Info, or nil when the bytes are not an image of a known format.
|
|
37
|
+
# width/height are nil when the header doesn't say (tiff, heic, or a
|
|
38
|
+
# truncated file).
|
|
39
|
+
def self.read(bytes)
|
|
40
|
+
b = bytes.to_s.b
|
|
41
|
+
format = format_of(b)
|
|
42
|
+
return nil unless format
|
|
43
|
+
|
|
44
|
+
width, height = dimensions(format, b)
|
|
45
|
+
Info.new(format: format, width: width, height: height)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.dimensions(format, b)
|
|
49
|
+
case format
|
|
50
|
+
when :png then b.bytesize >= 24 ? b[16, 8].unpack("NN") : nil
|
|
51
|
+
when :gif then b.bytesize >= 10 ? b[6, 4].unpack("vv") : nil
|
|
52
|
+
when :bmp then [b[18, 4].unpack1("l<").abs, b[22, 4].unpack1("l<").abs]
|
|
53
|
+
when :jpeg then jpeg_dimensions(b)
|
|
54
|
+
when :webp then webp_dimensions(b)
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Walks the JPEG segments to the first SOF marker (not DHT C4, JPG C8,
|
|
61
|
+
# DAC CC), which holds the height and width.
|
|
62
|
+
def self.jpeg_dimensions(b)
|
|
63
|
+
pos = 2
|
|
64
|
+
while pos + 9 < b.bytesize
|
|
65
|
+
return nil unless b.getbyte(pos) == 0xFF
|
|
66
|
+
|
|
67
|
+
marker = b.getbyte(pos + 1)
|
|
68
|
+
if marker == 0xFF
|
|
69
|
+
pos += 1
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
if marker.between?(0xC0, 0xCF) && ![0xC4, 0xC8, 0xCC].include?(marker)
|
|
73
|
+
height, width = b[pos + 5, 4].unpack("nn")
|
|
74
|
+
return [width, height]
|
|
75
|
+
end
|
|
76
|
+
pos += 2 + b[pos + 2, 2].unpack1("n")
|
|
77
|
+
end
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.webp_dimensions(b)
|
|
82
|
+
case b[12, 4]
|
|
83
|
+
when "VP8 "
|
|
84
|
+
w, h = b[26, 4].unpack("vv")
|
|
85
|
+
[w & 0x3FFF, h & 0x3FFF]
|
|
86
|
+
when "VP8L"
|
|
87
|
+
b0, b1, b2, b3 = b[21, 4].bytes
|
|
88
|
+
[1 + (((b1 & 0x3F) << 8) | b0), 1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6))]
|
|
89
|
+
when "VP8X"
|
|
90
|
+
w = b[24, 3].bytes
|
|
91
|
+
h = b[27, 3].bytes
|
|
92
|
+
[1 + (w[0] | (w[1] << 8) | (w[2] << 16)), 1 + (h[0] | (h[1] << 8) | (h[2] << 16))]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private_class_method :dimensions, :jpeg_dimensions, :webp_dimensions
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Downscales and converts images with whatever tool the machine has:
|
|
100
|
+
# macOS sips first, then ImageMagick. `none` when neither is installed.
|
|
101
|
+
class ImageResizer
|
|
102
|
+
Error = Class.new(StandardError)
|
|
103
|
+
|
|
104
|
+
attr_reader :tool
|
|
105
|
+
|
|
106
|
+
def self.detect
|
|
107
|
+
tool = if executable?("sips") then :sips
|
|
108
|
+
elsif executable?("magick") then :magick
|
|
109
|
+
end
|
|
110
|
+
new(tool)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.executable?(name)
|
|
114
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
115
|
+
path = File.join(dir, name)
|
|
116
|
+
File.file?(path) && File.executable?(path)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def initialize(tool)
|
|
121
|
+
@tool = tool
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def available?
|
|
125
|
+
!@tool.nil?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Writes +input+ to +output+ as +format+ (:png or :jpeg), no bigger than
|
|
129
|
+
# +max_side+ on its long side (never upscaled; nil keeps the size).
|
|
130
|
+
def convert(input, output, format:, max_side: nil, quality: 85)
|
|
131
|
+
raise Error, "no image tool (install ImageMagick)" unless available?
|
|
132
|
+
|
|
133
|
+
command = @tool == :sips ? sips_command(input, output, format, max_side, quality) : magick_command(input, output, format, max_side, quality)
|
|
134
|
+
out, status = Open3.capture2e(*command)
|
|
135
|
+
raise Error, "#{@tool} failed: #{out.strip[0, 200]}" unless status.success? && File.size?(output)
|
|
136
|
+
|
|
137
|
+
output
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def sips_command(input, output, format, max_side, quality)
|
|
143
|
+
command = ["sips", "-s", "format", format.to_s]
|
|
144
|
+
command += ["-s", "formatOptions", quality.to_s] if format == :jpeg
|
|
145
|
+
command += ["--resampleHeightWidthMax", max_side.to_s] if max_side
|
|
146
|
+
command + [input, "--out", output]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def magick_command(input, output, format, max_side, quality)
|
|
150
|
+
command = ["magick", "#{input}[0]"]
|
|
151
|
+
command += ["-resize", "#{max_side}x#{max_side}>"] if max_side
|
|
152
|
+
command += ["-quality", quality.to_s] if format == :jpeg
|
|
153
|
+
command + ["#{format}:#{output}"]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# One-line texts about an image ref ({file:, mime:, width:, height:,
|
|
158
|
+
# bytes:, name:, source:}).
|
|
159
|
+
module ImageRef
|
|
160
|
+
# Claude's rule of thumb (28×28 px per token). About 30% over on the
|
|
161
|
+
# local Qwen, close enough for a label and the context estimate.
|
|
162
|
+
def self.estimated_tokens(ref)
|
|
163
|
+
w = ref[:width].to_i
|
|
164
|
+
h = ref[:height].to_i
|
|
165
|
+
return 0 unless w.positive? && h.positive?
|
|
166
|
+
|
|
167
|
+
(w / 28.0).ceil * (h / 28.0).ceil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# "shot.png 1280×800 · ~1.3k tokens"
|
|
171
|
+
def self.label(ref)
|
|
172
|
+
tokens = estimated_tokens(ref)
|
|
173
|
+
count = tokens >= 1000 ? "~#{(tokens / 1000.0).round(1)}k" : "~#{tokens}"
|
|
174
|
+
"#{name(ref)} #{ref[:width]}×#{ref[:height]} · #{count} tokens"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The text sent instead of an image the request leaves out.
|
|
178
|
+
def self.placeholder(ref, reason)
|
|
179
|
+
"[image #{name(ref)} #{ref[:width]}×#{ref[:height]} not sent: #{reason}]"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.name(ref)
|
|
183
|
+
value = ref[:name].to_s
|
|
184
|
+
value.empty? ? File.basename(ref[:file].to_s) : value
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Stores images next to a session, in <session dir>/images/, under the
|
|
189
|
+
# hash of the bytes that are sent (so the same picture is stored once).
|
|
190
|
+
# Messages hold small refs to those files; the base64 is built only when
|
|
191
|
+
# a request is sent.
|
|
192
|
+
module ImageStore
|
|
193
|
+
Error = Class.new(StandardError)
|
|
194
|
+
|
|
195
|
+
DIR = "images"
|
|
196
|
+
REF_RE = %r{\Aimages/[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z}
|
|
197
|
+
MIME = { png: "image/png", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }.freeze
|
|
198
|
+
EXT = { png: "png", jpeg: "jpg", gif: "gif", webp: "webp" }.freeze
|
|
199
|
+
# Nothing larger is even read.
|
|
200
|
+
MAX_SOURCE_BYTES = 50 * 1024 * 1024
|
|
201
|
+
|
|
202
|
+
Limits = Data.define(:max_side, :max_bytes, :max_per_request) do
|
|
203
|
+
def self.from_config
|
|
204
|
+
new(max_side: positive(Config.get("image.max_side"), 1568),
|
|
205
|
+
max_bytes: positive(Config.get("image.max_bytes"), 3_750_000),
|
|
206
|
+
max_per_request: positive(Config.get("image.max_per_request"), 20))
|
|
207
|
+
rescue StandardError
|
|
208
|
+
new(max_side: 1568, max_bytes: 3_750_000, max_per_request: 20)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.positive(value, fallback)
|
|
212
|
+
value.to_i.positive? ? value.to_i : fallback
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# True when the file starts like an image of a known format.
|
|
217
|
+
def self.image_file?(path)
|
|
218
|
+
File.file?(path) && !ImageHeader.format_of(File.binread(path, 32)).nil?
|
|
219
|
+
rescue StandardError
|
|
220
|
+
false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Stores one image (from +path+ or +bytes+) in +session_dir+ and returns
|
|
224
|
+
# its ref. Converts bmp/tiff/heic to png, downscales to the long side,
|
|
225
|
+
# re-encodes as jpeg when still over max_bytes. Raises Error with a line
|
|
226
|
+
# for the user when the image can't be used.
|
|
227
|
+
def self.ingest(session_dir, path: nil, bytes: nil, name: nil, source: "user",
|
|
228
|
+
resizer: ImageResizer.detect, limits: Limits.from_config)
|
|
229
|
+
bytes = read_source(path) if path
|
|
230
|
+
raise Error, "no image given" if bytes.nil? || bytes.empty?
|
|
231
|
+
|
|
232
|
+
name = (name || (path && File.basename(path)) || "image").to_s
|
|
233
|
+
raise Error, "#{name} is too large (over 50 MB)" if bytes.bytesize > MAX_SOURCE_BYTES
|
|
234
|
+
info = ImageHeader.read(bytes)
|
|
235
|
+
raise Error, "#{name} is not an image chi can send (png, jpeg, gif, webp)" unless info
|
|
236
|
+
|
|
237
|
+
bytes, info = fit(bytes, info, name, resizer, limits)
|
|
238
|
+
store(session_dir, bytes, info, name, source)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A ref is only ever read when its file name matches REF_RE and the file
|
|
242
|
+
# is inside this session's images folder.
|
|
243
|
+
def self.valid_ref?(session_dir, ref)
|
|
244
|
+
return false unless ref.is_a?(Hash)
|
|
245
|
+
|
|
246
|
+
file = (ref[:file] || ref["file"]).to_s
|
|
247
|
+
return false unless REF_RE.match?(file)
|
|
248
|
+
|
|
249
|
+
path = File.join(session_dir.to_s, file)
|
|
250
|
+
File.file?(path) && !File.symlink?(path)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Messages seeded into another session (a plugin's ctx.sessions.fork):
|
|
254
|
+
# each image ref's file is copied from +from+ into +to+ (both session
|
|
255
|
+
# dirs; no +from+ copies none). A ref whose file isn't there is dropped, and its message says
|
|
256
|
+
# so ("[image shot.png was not copied]").
|
|
257
|
+
# @return [Array(Array<Hash>, Integer)] the messages, how many dropped
|
|
258
|
+
def self.copy_refs(messages, from:, to:)
|
|
259
|
+
dropped = 0
|
|
260
|
+
copied = Array(messages).map do |message|
|
|
261
|
+
images = message[:images] || message["images"]
|
|
262
|
+
next message unless images.is_a?(Array) && !images.empty?
|
|
263
|
+
|
|
264
|
+
kept, gone = from ? images.partition { |ref| valid_ref?(from, ref) } : [[], images]
|
|
265
|
+
kept.each do |ref|
|
|
266
|
+
file = (ref[:file] || ref["file"]).to_s
|
|
267
|
+
target = File.join(to.to_s, file)
|
|
268
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
269
|
+
FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
|
|
270
|
+
end
|
|
271
|
+
dropped += gone.size
|
|
272
|
+
message = message.reject { |key, _| key.to_s == "images" }
|
|
273
|
+
message[:images] = kept unless kept.empty?
|
|
274
|
+
unless gone.empty?
|
|
275
|
+
notes = gone.map { |ref| "[image #{ref.is_a?(Hash) ? ImageRef.name(symbolize(ref)) : "?"} was not copied]" }
|
|
276
|
+
message[:content] = [(message.delete("content") || message[:content]).to_s, *notes].reject(&:empty?).join("\n")
|
|
277
|
+
end
|
|
278
|
+
message
|
|
279
|
+
end
|
|
280
|
+
[copied, dropped]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A ref for an image already stored in this session (a web upload),
|
|
284
|
+
# rebuilt from the file itself: only its name comes from the caller.
|
|
285
|
+
def self.ref_for(session_dir, file:, name: nil, source: "user")
|
|
286
|
+
raise Error, "unknown image #{file.to_s[0, 80]}" unless valid_ref?(session_dir, { file: file })
|
|
287
|
+
|
|
288
|
+
bytes = File.binread(File.join(session_dir.to_s, file.to_s))
|
|
289
|
+
info = ImageHeader.read(bytes)
|
|
290
|
+
raise Error, "unknown image #{file}" unless info && MIME.key?(info.format) && info.width
|
|
291
|
+
|
|
292
|
+
name = File.basename(name.to_s).gsub(/[[:cntrl:]]/, "")[0, 120]
|
|
293
|
+
{ file: file.to_s, mime: MIME.fetch(info.format), width: info.width, height: info.height,
|
|
294
|
+
bytes: bytes.bytesize, name: name.empty? ? File.basename(file.to_s) : name, source: source.to_s }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The refs for a turn's images: {path:} is read and stored (only callers
|
|
298
|
+
# on this machine pass it), {file:} must already be in this session.
|
|
299
|
+
def self.resolve_all(session_dir, images, **options)
|
|
300
|
+
Array(images).map do |image|
|
|
301
|
+
image = symbolize(image)
|
|
302
|
+
raise Error, "bad image #{image.inspect[0, 80]}" unless image.is_a?(Hash)
|
|
303
|
+
|
|
304
|
+
if image[:path]
|
|
305
|
+
ingest(session_dir, path: image[:path], name: image[:name], source: "user", **options)
|
|
306
|
+
else
|
|
307
|
+
ref_for(session_dir, file: image[:file], name: image[:name])
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def self.path_for(session_dir, ref)
|
|
313
|
+
raise Error, "bad image ref" unless valid_ref?(session_dir, ref)
|
|
314
|
+
|
|
315
|
+
File.join(session_dir.to_s, (ref[:file] || ref["file"]).to_s)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def self.base64(session_dir, ref)
|
|
319
|
+
[File.binread(path_for(session_dir, ref))].pack("m0")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def self.data_uri(session_dir, ref)
|
|
323
|
+
"data:#{ref[:mime] || ref["mime"]};base64,#{base64(session_dir, ref)}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# A ref read back from JSON (string keys) with symbol keys.
|
|
327
|
+
def self.symbolize(ref)
|
|
328
|
+
return ref unless ref.is_a?(Hash)
|
|
329
|
+
|
|
330
|
+
ref.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def self.read_source(path)
|
|
334
|
+
expanded = File.expand_path(path.to_s)
|
|
335
|
+
raise Error, "#{path}: no such file" unless File.exist?(expanded)
|
|
336
|
+
raise Error, "#{path} is not a file" unless File.file?(expanded)
|
|
337
|
+
raise Error, "#{path} is too large (over 50 MB)" if File.size(expanded) > MAX_SOURCE_BYTES
|
|
338
|
+
|
|
339
|
+
File.binread(expanded)
|
|
340
|
+
rescue SystemCallError => e
|
|
341
|
+
raise Error, "#{path}: #{e.message}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def self.fit(bytes, info, name, resizer, limits)
|
|
345
|
+
convert = ImageHeader::CONVERTIBLE.include?(info.format)
|
|
346
|
+
too_wide = info.width && info.height && [info.width, info.height].max > limits.max_side
|
|
347
|
+
too_heavy = bytes.bytesize > limits.max_bytes
|
|
348
|
+
return [bytes, info] unless convert || too_wide || too_heavy
|
|
349
|
+
|
|
350
|
+
unless resizer.available?
|
|
351
|
+
what = convert ? "a #{info.format} image" : "too large (#{info.width}×#{info.height}, #{bytes.bytesize} bytes)"
|
|
352
|
+
raise Error, "#{name} is #{what}; install ImageMagick or downscale it"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
Dir.mktmpdir("chi-image") do |dir|
|
|
356
|
+
input = File.join(dir, "in")
|
|
357
|
+
File.binwrite(input, bytes)
|
|
358
|
+
# sips upscales to --resampleHeightWidthMax, so pass it only to shrink.
|
|
359
|
+
side = too_wide ? limits.max_side : nil
|
|
360
|
+
out = run(resizer, input, File.join(dir, "out.png"), :png, side)
|
|
361
|
+
out = run(resizer, input, File.join(dir, "out.jpg"), :jpeg, side) if File.size(out) > limits.max_bytes
|
|
362
|
+
result = File.binread(out)
|
|
363
|
+
raise Error, "#{name} is still over #{limits.max_bytes} bytes after downscaling" if result.bytesize > limits.max_bytes
|
|
364
|
+
|
|
365
|
+
[result, ImageHeader.read(result)]
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def self.run(resizer, input, output, format, max_side)
|
|
370
|
+
resizer.convert(input, output, format: format, max_side: max_side)
|
|
371
|
+
rescue ImageResizer::Error => e
|
|
372
|
+
raise Error, e.message
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def self.store(session_dir, bytes, info, name, source)
|
|
376
|
+
raise Error, "#{name}: can't read its size" unless info&.width && info.height
|
|
377
|
+
raise Error, "#{name} is not an image chi can send" unless MIME.key?(info.format)
|
|
378
|
+
|
|
379
|
+
file = "#{DIR}/#{Digest::SHA256.hexdigest(bytes)[0, 16]}.#{EXT.fetch(info.format)}"
|
|
380
|
+
path = File.join(session_dir.to_s, file)
|
|
381
|
+
unless File.file?(path)
|
|
382
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
383
|
+
tmp = "#{path}.#{SecureRandom.hex(4)}.tmp"
|
|
384
|
+
File.binwrite(tmp, bytes)
|
|
385
|
+
File.rename(tmp, path)
|
|
386
|
+
end
|
|
387
|
+
{ file: file, mime: MIME.fetch(info.format), width: info.width, height: info.height,
|
|
388
|
+
bytes: bytes.bytesize, name: name, source: source.to_s }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
private_class_method :read_source, :fit, :run, :store
|
|
392
|
+
end
|
|
393
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Whether this code runs as an installed gem, and where that gem's
|
|
5
|
+
# RubyGems wrapper (`chi` on the PATH) is. A source checkout (bin/chi, or
|
|
6
|
+
# bundle exec with the Gemfile's `gemspec`) is not an installed gem.
|
|
7
|
+
module InstalledGem
|
|
8
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The installed spec RubyGems activated (the `chi` wrapper does), from a
|
|
13
|
+
# specifications/ dir, when its directory is this code's; else nil.
|
|
14
|
+
def spec(gem_dir = ROOT)
|
|
15
|
+
spec = Gem.loaded_specs["samagotchi"]
|
|
16
|
+
return nil unless spec&.loaded_from && File.basename(File.dirname(spec.loaded_from)) == "specifications"
|
|
17
|
+
|
|
18
|
+
real_path(spec.full_gem_path) == real_path(gem_dir) ? spec : nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The wrapper RubyGems wrote for the gem's `chi` (GEM_HOME/bin/chi and
|
|
22
|
+
# the like): it activates the newest installed samagotchi, so a path to
|
|
23
|
+
# it outlives upgrades and `gem cleanup`, unlike <gem dir>/bin/chi.
|
|
24
|
+
# nil when the wrapper isn't there (e.g. installed with --bindir).
|
|
25
|
+
def wrapper(gem_spec = spec)
|
|
26
|
+
return nil unless gem_spec
|
|
27
|
+
|
|
28
|
+
path = File.join(Gem.bindir(gem_spec.base_dir), "chi")
|
|
29
|
+
File.file?(path) ? path : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def real_path(path)
|
|
33
|
+
File.realpath(path)
|
|
34
|
+
rescue SystemCallError
|
|
35
|
+
File.expand_path(path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|