samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -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,
@@ -36,17 +38,27 @@ module Samagotchi
36
38
  label: label,
37
39
  command: targets&.command,
38
40
  paths: targets && !targets.paths.empty? ? targets.paths : nil,
41
+ # No command or path (an MCP tool): its arguments, as the question.
42
+ args: args_for_card(targets),
39
43
  cwd: targets&.cwd,
40
44
  repo_root: targets&.repo_root,
41
45
  branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
42
46
  rule: verdict.rule,
43
47
  source: verdict.source,
44
48
  reason: verdict.reason,
45
- scopes: scopes
49
+ scopes: scopes,
50
+ preview: preview
46
51
  }.compact
47
52
  }
48
53
  end
49
54
 
55
+ def args_for_card(targets)
56
+ return nil if targets.nil? || targets.command || !targets.paths.empty?
57
+
58
+ text = Approval.args_text(targets.args, limit: ARGS_CHARS)
59
+ text.empty? ? nil : text
60
+ end
61
+
50
62
  # Settle +verdict+ from open_question's result.
51
63
  # @param answer [Hash, String] {selected_indices:, freeform:} or {error:}
52
64
  # @param scopes [Array<String>] the offered scopes, in option order
@@ -103,7 +115,8 @@ module Samagotchi
103
115
  # execute: git push origin main
104
116
  # in /path/to/repo (repo samagotchi, branch main)
105
117
  # why: git push publishes commits (rule git-push, bundle guardrails)
106
- def question_text(verdict, label: nil)
118
+ # change: +3 −1 (edit/write, from the preview)
119
+ def question_text(verdict, label: nil, preview: nil)
107
120
  targets = verdict.targets
108
121
  tool = label || targets&.tool || verdict.call[:name].to_s
109
122
  what = targets&.command || (targets && targets.paths.join(", "))
@@ -118,8 +131,26 @@ module Samagotchi
118
131
  who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
119
132
  reason = verdict.reason.to_s.strip
120
133
  lines << " why: #{reason.empty? ? "(no reason given)" : reason} (#{who})"
134
+ change = change_text(preview)
135
+ lines << " change: #{change}" if change
121
136
  lines.join("\n")
122
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
123
154
  end
124
155
  end
125
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 }
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_paths"
4
+ require_relative "../memory_paths"
5
+
6
+ module Samagotchi
7
+ module Guardrails
8
+ # A `chi scratch` session saves nothing: write and edit on a file under
9
+ # the memories folder (system and project memories, overlays, index.md)
10
+ # are denied. (memory_write itself refuses in a scratch session, with a
11
+ # shorter answer: Engine.) Reading them is fine. execute can still
12
+ # write there (as with ProtectedPaths).
13
+ class ScratchWrites
14
+ REASON = "scratch session: nothing is saved"
15
+ RULE = "scratch-session"
16
+
17
+ # @param memories_dir [#call] the memories folder (MemoryPaths.system_dir),
18
+ # read per call like the memory tools do
19
+ def initialize(memories_dir: -> { MemoryPaths.system_dir })
20
+ @memories_dir = memories_dir
21
+ end
22
+
23
+ # Vote on +verdict+.
24
+ def check(verdict)
25
+ return verdict unless writes_memory?(verdict)
26
+
27
+ verdict.deny!(REASON, rule: RULE, source: ProtectedPaths::SOURCE, decided_by: "core")
28
+ end
29
+
30
+ private
31
+
32
+ def writes_memory?(verdict)
33
+ targets = verdict.targets
34
+ return false unless targets && %w[write edit].include?(targets.tool)
35
+
36
+ targets.paths.any? { |path| ProtectedPaths.within?(ProtectedPaths.real(path), @memories_dir.call) }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -6,6 +6,7 @@ require_relative "guardrails/targets"
6
6
  require_relative "guardrails/approval"
7
7
  require_relative "guardrails/approvals"
8
8
  require_relative "guardrails/protected_paths"
9
+ require_relative "guardrails/scratch_writes"
9
10
  require_relative "guardrails/load_failures"
10
11
  require_relative "guardrails/rules"
11
12
  require_relative "guardrails/gate"
@@ -15,14 +15,15 @@ module Samagotchi
15
15
  takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
16
16
  end
17
17
 
18
- # What a hook can do beyond reading its event: the Engine's three
18
+ # What a hook can do beyond reading its event: the Engine's
19
19
  # callables, each given the hook's label. +notify+ takes
20
20
  # (text:, level:, hook:) and shows one line to the user; +ask_user+
21
21
  # takes (question:, options:, header:, allow_freeform:, hook:) and
22
22
  # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
23
- # cancels the running turn (true when it did). A registry without one
24
- # gives hooks no-op helpers.
25
- Runtime = Struct.new(:notify, :ask_user, :stop_turn, keyword_init: true)
23
+ # cancels the running turn (true when it did); +steer+ takes (text:,
24
+ # hook:) and puts the text into the running turn (Engine#steer, true
25
+ # when queued). A registry without one gives hooks no-op helpers.
26
+ Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, keyword_init: true)
26
27
 
27
28
  # A thread-safe registry for named hook callbacks.
28
29
  #
@@ -172,6 +173,12 @@ module Samagotchi
172
173
  # event[:stop_turn].call(reason) -> true when the turn was cancelled;
173
174
  # in a before_tool_call event it also denies the call; from
174
175
  # after_turn / session_end it does nothing (false)
176
+ # event[:steer].call(text) -> true when the text was queued for the
177
+ # running turn's next boundary (its own user message, source: the
178
+ # hook's bundle); from after_turn / session_end false
179
+ # and after_turn's fire site adds one more (AnswerDisplay):
180
+ # event[:present].call { |text| new_text } -> the answer's display
181
+ # text after the call (chained in hook order), nil without an answer
175
182
  #
176
183
  # @param name [Symbol] the hook name to fire
177
184
  # @param event [Hash] the event payload (may be mutated by hooks)
@@ -191,6 +198,13 @@ module Samagotchi
191
198
  end
192
199
  end
193
200
 
201
+ # Whether a #fire of +name+ would call any hook.
202
+ # @param name [Symbol]
203
+ # @return [Boolean]
204
+ def any?(name)
205
+ !ordered_procs(name).empty?
206
+ end
207
+
194
208
  # Like #fire, and yields the event after each hook (a raising one
195
209
  # too), so the caller can fold what that hook did before the next one
196
210
  # runs (the guardrail gate keeps a deny sticky this way).
@@ -235,7 +249,7 @@ module Samagotchi
235
249
  end
236
250
  end
237
251
 
238
- # The three helpers, once per fire; a fire site's own keys stay.
252
+ # The helpers, once per fire; a fire site's own keys stay.
239
253
  def with_runtime(event)
240
254
  return unless event.is_a?(Hash)
241
255
 
@@ -255,6 +269,11 @@ module Samagotchi
255
269
  end
256
270
  @runtime&.stop_turn&.call(reason: reason.to_s, hook: event[:hook]) ? true : false
257
271
  }
272
+ event[:steer] ||= lambda { |text|
273
+ next false if TURN_OVER_EVENTS.include?(event[:type])
274
+
275
+ @runtime&.steer&.call(text: text.to_s, hook: event[:hook]) ? true : false
276
+ }
258
277
  end
259
278
  end
260
279
  end
@@ -28,9 +28,11 @@ module Samagotchi
28
28
  # come from it); api_key_env: the variable holding the host's API key;
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
- # vision: the configured true/false (VisionSupport), nil when unset.
31
+ # vision: the configured true/false (VisionSupport), nil when unset;
32
+ # sampling: the configured request parameters (SamplingSettings), nil when unset;
33
+ # thinking: the configured level (Thinking), nil when unset.
32
34
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
33
- :first_token_timeout, :vision, keyword_init: true) do
35
+ :first_token_timeout, :vision, :sampling, :thinking, keyword_init: true) do
34
36
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
35
37
  # raw-prompt loop.
36
38
  def chat? = api == :openai
@@ -86,9 +88,10 @@ module Samagotchi
86
88
  entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
87
89
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
88
90
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
89
- vision: cfg[:vision])
91
+ vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking])
90
92
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
91
- 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)
92
95
  @entries[entry.name] = entry
93
96
  end
94
97
  # Fallback single entry (should already be synthesized by hosts_config, but guard)
@@ -116,10 +119,6 @@ module Samagotchi
116
119
  nil
117
120
  end
118
121
 
119
- def entry_names
120
- @entries.keys
121
- end
122
-
123
122
  def default_entry
124
123
  @entries["default"] || @entries.values.first
125
124
  end
@@ -148,10 +147,8 @@ module Samagotchi
148
147
  rescue StandardError
149
148
  nil
150
149
  end
151
- entry = find_entry(host_ref)
152
- return [entry, bare] if entry
153
- # Unknown prefix — treat as bare model on default host
154
- 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]
155
152
  end
156
153
  # Unqualified: also try alias resolution for discovery (small -> gemma-small or small -> small-box:gemma-small)
157
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
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "idle_client"
9
9
  require_relative "output_formatter"
10
+ require_relative "steer"
10
11
  require_relative "recap_store"
11
12
  require_relative "log"
12
13
 
@@ -60,6 +61,9 @@ module Samagotchi
60
61
 
61
62
  case message["role"]
62
63
  when "user"
64
+ # A plugin's steer is a prod to the model, not what the user said.
65
+ next nil if Steer.steer?(message)
66
+
63
67
  # An image is a line naming it (refs only, never its bytes).
64
68
  [message["content"].to_s, *image_lines(message["images"])].reject(&:empty?).join("\n")
65
69
  when "model", "assistant"
@@ -411,7 +415,7 @@ module Samagotchi
411
415
  parsed = safe_parse(@engine.messages_json_for_recap)
412
416
  return :too_short if parsed.nil?
413
417
 
414
- user_turns = parsed.count { |message| message.is_a?(Hash) && message["role"] == "user" }
418
+ user_turns = parsed.count { |message| Steer.prompt?(message) }
415
419
  if parsed.empty? || user_turns < @min_user_turns
416
420
  drop_stale(parsed)
417
421
  return :too_short
@@ -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")