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.
Files changed (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. 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