samagotchi 0.3.0 → 0.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "rubygems/package"
8
+ require_relative "version"
9
+
10
+ module Samagotchi
11
+ # The gem part of `chi update`: the newest samagotchi on rubygems, and a
12
+ # `gem install` of it through this Ruby's own gem command (the real
13
+ # interpreter's bindir, not a shim: the same under mise, rbenv and asdf).
14
+ #
15
+ # SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
16
+ # file the newest version and installs it instead.
17
+ class GemUpdate
18
+ Error = Class.new(StandardError)
19
+
20
+ LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
21
+ # Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.
22
+ LOCAL_ENV = "SAMAGOTCHI_UPDATE_GEM_FILE"
23
+ TIMEOUT = 5
24
+
25
+ # source is what `gem install` takes: the gem's name, or a .gem file.
26
+ Latest = Struct.new(:version, :source, keyword_init: true)
27
+
28
+ # Runs a command without a shell; [output, success].
29
+ class Runner
30
+ def run(argv)
31
+ output, status = Open3.capture2e(*argv)
32
+ [output, status.success?]
33
+ rescue SystemCallError => e
34
+ [e.message, false]
35
+ end
36
+ end
37
+
38
+ # @param fetcher [#call, nil] → the latest.json body (specs)
39
+ def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: File.join(RbConfig::CONFIG["bindir"], "gem"))
40
+ @env = env
41
+ @fetcher = fetcher || method(:fetch)
42
+ @runner = runner
43
+ @gem_bin = gem_bin
44
+ end
45
+
46
+ # @return [Latest]
47
+ # @raise [Error] when it can't be found out (offline, a bad answer)
48
+ def latest
49
+ local = @env[LOCAL_ENV].to_s
50
+ return Latest.new(version: Gem::Package.new(local).spec.version.to_s, source: local) unless local.empty?
51
+
52
+ version = JSON.parse(@fetcher.call.to_s)["version"].to_s
53
+ raise Error, "rubygems.org answered without a version" if version.empty? || !Gem::Version.correct?(version)
54
+
55
+ Latest.new(version: version, source: "samagotchi")
56
+ rescue Error
57
+ raise
58
+ rescue StandardError => e
59
+ raise Error, "#{e.class}: #{e.message}".lines.first.strip
60
+ end
61
+
62
+ def newer?(latest, current = VERSION)
63
+ Gem::Version.new(latest.version) > Gem::Version.new(current)
64
+ end
65
+
66
+ def install_argv(latest)
67
+ argv = [@gem_bin, "install", latest.source, "--no-document"]
68
+ latest.source == "samagotchi" ? argv + ["-v", latest.version] : argv
69
+ end
70
+
71
+ # @return [Array(String, Boolean)] gem's output, success
72
+ def install(latest)
73
+ @runner.run(install_argv(latest))
74
+ end
75
+
76
+ private
77
+
78
+ def fetch
79
+ Net::HTTP.start(LATEST_URL.host, LATEST_URL.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
80
+ request = Net::HTTP::Get.new(LATEST_URL)
81
+ request["User-Agent"] = USER_AGENT
82
+ response = http.request(request)
83
+ raise Error, "rubygems.org answered #{response.code}" unless response.code == "200"
84
+
85
+ response.body
86
+ end
87
+ end
88
+ end
89
+ end
@@ -20,12 +20,14 @@ module Samagotchi
20
20
  # @param label [String, nil] a plugin tool's label ("chrome:
21
21
  # screenshot"): the question names the tool by it, as its row does;
22
22
  # approval[:tool] stays the raw name
23
+ # @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
24
+ # write: the card shows its diff, the question text one change line
23
25
  # @return [Hash] open_question fields
24
- def payload(verdict, label: nil)
26
+ def payload(verdict, label: nil, preview: nil)
25
27
  scopes = offered_scopes(verdict)
26
28
  targets = verdict.targets
27
29
  {
28
- question: question_text(verdict, label: label),
30
+ question: question_text(verdict, label: label, preview: preview),
29
31
  options: scopes.map { |scope| label(scope, verdict) } + [DENY],
30
32
  header: HEADER,
31
33
  multi_select: false,
@@ -44,7 +46,8 @@ module Samagotchi
44
46
  rule: verdict.rule,
45
47
  source: verdict.source,
46
48
  reason: verdict.reason,
47
- scopes: scopes
49
+ scopes: scopes,
50
+ preview: preview
48
51
  }.compact
49
52
  }
50
53
  end
@@ -112,7 +115,8 @@ module Samagotchi
112
115
  # execute: git push origin main
113
116
  # in /path/to/repo (repo samagotchi, branch main)
114
117
  # why: git push publishes commits (rule git-push, bundle guardrails)
115
- def question_text(verdict, label: nil)
118
+ # change: +3 −1 (edit/write, from the preview)
119
+ def question_text(verdict, label: nil, preview: nil)
116
120
  targets = verdict.targets
117
121
  tool = label || targets&.tool || verdict.call[:name].to_s
118
122
  what = targets&.command || (targets && targets.paths.join(", "))
@@ -127,8 +131,26 @@ module Samagotchi
127
131
  who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
128
132
  reason = verdict.reason.to_s.strip
129
133
  lines << " why: #{reason.empty? ? "(no reason given)" : reason} (#{who})"
134
+ change = change_text(preview)
135
+ lines << " change: #{change}" if change
130
136
  lines.join("\n")
131
137
  end
138
+
139
+ # One line for an EditPreview: "+3 −1", "new file, 12 lines",
140
+ # "would fail: …" or "not shown (binary file)". The diff itself stays
141
+ # out of the question text (size); the card and the TUI show it.
142
+ def change_text(preview)
143
+ return nil unless preview.is_a?(Hash)
144
+
145
+ get = ->(key) { preview[key] || preview[key.to_s] }
146
+ return "would fail: #{get.(:error)}" if get.(:error)
147
+ return "not shown (#{get.(:skipped)})" if get.(:skipped)
148
+
149
+ added = get.(:added).to_i
150
+ return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.(:new_file)
151
+
152
+ "+#{added} −#{get.(:removed).to_i}"
153
+ end
132
154
  end
133
155
  end
134
156
  end
@@ -7,7 +7,7 @@ module Samagotchi
7
7
  # guardrail that silently vanished is the failure this guards against);
8
8
  # the rest are only announced.
9
9
  class LoadFailures
10
- Failure = Struct.new(:what, :reason, :required, keyword_init: true)
10
+ Failure = Struct.new(:what, :reason, :required, :group, keyword_init: true)
11
11
 
12
12
  def initialize
13
13
  @list = []
@@ -15,8 +15,14 @@ module Samagotchi
15
15
  end
16
16
 
17
17
  # @param what [String] e.g. "hook guard.rb (config)"
18
- def add(what, reason, required:)
19
- @mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required) }
18
+ # @param group [Symbol, nil] what loads it again (#drop): :rules
19
+ def add(what, reason, required:, group: nil)
20
+ @mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required, group: group) }
21
+ end
22
+
23
+ # Forget a group's failures before it loads again.
24
+ def drop(group)
25
+ @mutex.synchronize { @list.reject! { |failure| failure.group == group } }
20
26
  end
21
27
 
22
28
  def list = @mutex.synchronize { @list.dup }
@@ -29,9 +29,10 @@ module Samagotchi
29
29
  # profile: the configured prompt profile name, if any;
30
30
  # first_token_timeout: the configured first-token limit (see #first_token_limit);
31
31
  # vision: the configured true/false (VisionSupport), nil when unset;
32
- # sampling: the configured request parameters (SamplingSettings), nil when unset.
32
+ # sampling: the configured request parameters (SamplingSettings), nil when unset;
33
+ # thinking: the configured level (Thinking), nil when unset.
33
34
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
34
- :first_token_timeout, :vision, :sampling, keyword_init: true) do
35
+ :first_token_timeout, :vision, :sampling, :thinking, keyword_init: true) do
35
36
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
36
37
  # raw-prompt loop.
37
38
  def chat? = api == :openai
@@ -87,9 +88,10 @@ module Samagotchi
87
88
  entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
88
89
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
89
90
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
90
- vision: cfg[:vision], sampling: cfg[:sampling])
91
+ vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking])
91
92
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
92
- first_token_timeout: entry.first_token_limit, name: entry.name)
93
+ first_token_timeout: entry.first_token_limit, name: entry.name,
94
+ api_key_env: entry.api_key_env, env: env)
93
95
  @entries[entry.name] = entry
94
96
  end
95
97
  # Fallback single entry (should already be synthesized by hosts_config, but guard)
@@ -117,10 +119,6 @@ module Samagotchi
117
119
  nil
118
120
  end
119
121
 
120
- def entry_names
121
- @entries.keys
122
- end
123
-
124
122
  def default_entry
125
123
  @entries["default"] || @entries.values.first
126
124
  end
@@ -149,10 +147,8 @@ module Samagotchi
149
147
  rescue StandardError
150
148
  nil
151
149
  end
152
- entry = find_entry(host_ref)
153
- return [entry, bare] if entry
154
- # Unknown prefix — treat as bare model on default host
155
- return [default_entry, raw_model.to_s.strip]
150
+ # parse_qualified_model names a host only when it is one of ours
151
+ return [find_entry(host_ref), bare]
156
152
  end
157
153
  # Unqualified: also try alias resolution for discovery (small -> gemma-small or small -> small-box:gemma-small)
158
154
  begin
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "llm/openai_chat"
5
+ require_relative "thinking"
6
+ require_relative "log"
5
7
 
6
8
  module Samagotchi
7
9
  # Standalone summarizer for the idle session-recap feature.
@@ -33,17 +35,6 @@ module Samagotchi
33
35
  # Up to 10 sentences (recap.sentences) need ~350 tokens with thinking off.
34
36
  MAX_TOKENS = 512
35
37
 
36
- # Request fields that turn thinking off. A reasoning model otherwise
37
- # spends the budget thinking and the recap stops mid-sentence.
38
- # - chat_template_kwargs.enable_thinking: the chat template's switch
39
- # (llama.cpp with Qwen/Gemma templates); templates without it ignore it.
40
- # - reasoning_effort "none": the OpenAI-style knob, for servers that
41
- # ignore the template switch (Splash thought until max_tokens).
42
- THINKING_OFF = {
43
- chat_template_kwargs: { enable_thinking: false },
44
- reasoning_effort: "none"
45
- }.freeze
46
-
47
38
  # @param base_url [String] the OpenAI API base, e.g. http://host:8081/v1
48
39
  # @param api_key_env [String, nil] the variable holding the host's key
49
40
  # @param timeout [Numeric] HTTP request timeout. Kept to the recap's own
@@ -119,10 +110,17 @@ module Samagotchi
119
110
  # +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
120
111
  # or all of it, with "…".
121
112
  def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true)
122
- response = @chat.chat(
123
- messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
124
- options: { max_tokens: max_tokens, **THINKING_OFF }
125
- )
113
+ response = begin
114
+ request(messages, max_tokens, cancel_controller)
115
+ rescue LLM::BadRequest => e
116
+ raise unless e.reasoning_refused? && !@thinking_refused
117
+
118
+ # The host won't turn thinking off (gpt-oss): ask again without the
119
+ # fields, and leave them out from now on.
120
+ @thinking_refused = true
121
+ Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
122
+ request(messages, max_tokens, cancel_controller)
123
+ end
126
124
  content = response.text
127
125
  reasoning = response.reasoning
128
126
  raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
@@ -143,5 +141,16 @@ module Samagotchi
143
141
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
144
142
  end
145
143
 
144
+ def request(messages, max_tokens, cancel_controller)
145
+ @chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
146
+ options: { max_tokens: max_tokens, **thinking_fields })
147
+ end
148
+
149
+ # A recap or side answer is short and tool-less: thinking off, whatever
150
+ # the model's level (a reasoning model otherwise spends the budget
151
+ # thinking and the recap stops mid-sentence). None once the host refused them.
152
+ def thinking_fields
153
+ @thinking_refused ? {} : Thinking.chat_fields(:off)
154
+ end
146
155
  end
147
156
  end
@@ -9,7 +9,7 @@ module Samagotchi
9
9
  # (one background thread for the whole idle layer).
10
10
  #
11
11
  # Two delivery paths cooperate:
12
- # 1. Pull-based: Engine#maybe_inject_reminders / collect_due_reminders
12
+ # 1. Pull-based: Engine#collect_due_reminders
13
13
  # reads ReminderStore#due_reminders at turn start → injects
14
14
  # [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
15
15
  # 2. Synthetic turns: when this job's tick finds due reminders while
@@ -43,7 +43,7 @@ module Samagotchi
43
43
  @mutex.synchronize { @due_reminder_name }
44
44
  end
45
45
 
46
- # Get all due reminders (from ReminderStore). Called by Engine#maybe_inject_reminders.
46
+ # Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
47
47
  # Thread-safe. Returns all due reminders, not just one.
48
48
  # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
49
49
  def due_reminders
@@ -262,12 +262,7 @@ module Samagotchi
262
262
  next message unless images.is_a?(Array) && !images.empty?
263
263
 
264
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
265
+ kept.each { |ref| copy_file(ref, from: from, to: to) }
271
266
  dropped += gone.size
272
267
  message = message.reject { |key, _| key.to_s == "images" }
273
268
  message[:images] = kept unless kept.empty?
@@ -280,6 +275,15 @@ module Samagotchi
280
275
  [copied, dropped]
281
276
  end
282
277
 
278
+ # One stored image's file from session dir +from+ into +to+ (kept when
279
+ # it is there already: the name is the content's hash).
280
+ def self.copy_file(ref, from:, to:)
281
+ file = (ref[:file] || ref["file"]).to_s
282
+ target = File.join(to.to_s, file)
283
+ FileUtils.mkdir_p(File.dirname(target))
284
+ FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
285
+ end
286
+
283
287
  # A ref for an image already stored in this session (a web upload),
284
288
  # rebuilt from the file itself: only its name comes from the caller.
285
289
  def self.ref_for(session_dir, file:, name: nil, source: "user")