samagotchi 0.8.1 → 0.10.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +9 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +47 -17
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +29 -29
  12. data/lib/samagotchi/bridge/card_store.rb +4 -4
  13. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  14. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  16. data/lib/samagotchi/bridge.rb +59 -112
  17. data/lib/samagotchi/bridge_client.rb +11 -28
  18. data/lib/samagotchi/bundle_command.rb +158 -212
  19. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  21. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  22. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  25. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  27. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  32. data/lib/samagotchi/bundles/system/identity.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  35. data/lib/samagotchi/cli/command.rb +51 -0
  36. data/lib/samagotchi/cli/flags.rb +116 -0
  37. data/lib/samagotchi/config.rb +70 -55
  38. data/lib/samagotchi/config_text_edit.rb +107 -0
  39. data/lib/samagotchi/desktop_command.rb +24 -18
  40. data/lib/samagotchi/edit_preview.rb +2 -2
  41. data/lib/samagotchi/engine.rb +20 -75
  42. data/lib/samagotchi/events.rb +23 -0
  43. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  44. data/lib/samagotchi/guardrails/targets.rb +7 -1
  45. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  46. data/lib/samagotchi/hooks/loader.rb +23 -33
  47. data/lib/samagotchi/hooks/registry.rb +41 -0
  48. data/lib/samagotchi/hooks.rb +0 -3
  49. data/lib/samagotchi/host_registry.rb +15 -0
  50. data/lib/samagotchi/idle_reminders.rb +0 -7
  51. data/lib/samagotchi/image_store.rb +22 -0
  52. data/lib/samagotchi/kernel_loop.rb +12 -16
  53. data/lib/samagotchi/live_versions.rb +2 -13
  54. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  55. data/lib/samagotchi/log_subscriber.rb +5 -1
  56. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  57. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  59. data/lib/samagotchi/memory_bundle/installer.rb +25 -30
  60. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  61. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  63. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  64. data/lib/samagotchi/memory_paths.rb +10 -0
  65. data/lib/samagotchi/note_command.rb +23 -37
  66. data/lib/samagotchi/owner_lock.rb +14 -3
  67. data/lib/samagotchi/plugin/api.rb +11 -17
  68. data/lib/samagotchi/plugin/context.rb +70 -15
  69. data/lib/samagotchi/plugin/loader.rb +2 -3
  70. data/lib/samagotchi/sampling_settings.rb +3 -5
  71. data/lib/samagotchi/self_report.rb +6 -9
  72. data/lib/samagotchi/send_command.rb +33 -52
  73. data/lib/samagotchi/session.rb +88 -92
  74. data/lib/samagotchi/session_archive_command.rb +8 -14
  75. data/lib/samagotchi/session_commands.rb +3 -21
  76. data/lib/samagotchi/session_delete_command.rb +15 -29
  77. data/lib/samagotchi/session_manager.rb +29 -15
  78. data/lib/samagotchi/session_metrics.rb +2 -1
  79. data/lib/samagotchi/sessions_command.rb +3 -2
  80. data/lib/samagotchi/system_prompt.rb +2 -1
  81. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  83. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  85. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  86. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  87. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  88. data/lib/samagotchi/terminal_ui.rb +156 -234
  89. data/lib/samagotchi/thinking.rb +2 -3
  90. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  91. data/lib/samagotchi/tool_call_parser.rb +56 -292
  92. data/lib/samagotchi/tool_declarations.rb +1 -1
  93. data/lib/samagotchi/tool_runner.rb +8 -0
  94. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  95. data/lib/samagotchi/tools/builtins.rb +2 -1
  96. data/lib/samagotchi/tools/edit.rb +21 -29
  97. data/lib/samagotchi/tools/memory.rb +2 -13
  98. data/lib/samagotchi/tools/send_note.rb +1 -1
  99. data/lib/samagotchi/update_command.rb +32 -22
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/vision_support.rb +4 -5
  102. data/lib/samagotchi/web/app.rb +89 -151
  103. data/lib/samagotchi/web/public/activity.js +1 -11
  104. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  105. data/lib/samagotchi/web/public/app.js +158 -785
  106. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  107. data/lib/samagotchi/web/public/command_list.js +99 -0
  108. data/lib/samagotchi/web/public/data.js +22 -66
  109. data/lib/samagotchi/web/public/format.js +0 -23
  110. data/lib/samagotchi/web/public/index.html +0 -2
  111. data/lib/samagotchi/web/public/model_pick.js +0 -7
  112. data/lib/samagotchi/web/public/notifications.js +169 -0
  113. data/lib/samagotchi/web/public/question_cards.js +352 -0
  114. data/lib/samagotchi/web/public/session_state.js +34 -0
  115. data/lib/samagotchi/web/public/timing.js +1 -1
  116. data/lib/samagotchi/web/public/turn_events.js +11 -2
  117. data/lib/samagotchi/web/public/turn_model.js +0 -14
  118. data/lib/samagotchi/web/session_hub.rb +6 -4
  119. data/lib/samagotchi/web/session_summary.rb +5 -3
  120. data/lib/samagotchi/worker.rb +59 -53
  121. data/lib/samagotchi/worker_sidecar.rb +27 -1
  122. metadata +13 -1
@@ -30,9 +30,9 @@ module Samagotchi
30
30
  # Any hook whose sha256 differs is not loaded (reinstall the bundle
31
31
  # after editing a hook by hand).
32
32
  #
33
- # The +on_error+ policy is applied per hook at fire time:
34
- # - "fail_closed" (on :before_tool_call): a raising guardrail sets
35
- # event[:blocked] = true — the tool call is prevented (fail-closed).
33
+ # The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
34
+ # - "fail_closed" (on :before_tool_call): a raising guardrail denies
35
+ # the tool call (fail-closed).
36
36
  # - "log": warn to stderr and continue.
37
37
  # - "skip" (default): silently continue.
38
38
  module BundleLoader
@@ -80,16 +80,14 @@ module Samagotchi
80
80
 
81
81
  begin
82
82
  plugin = instantiate(bundle_name, basename, file, settings: settings)
83
- log_failure = Hooks.failure_log_gate(event_sym)
84
-
85
- registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority) do |event|
86
- begin
87
- plugin.call(event)
88
- rescue Exception => e
89
- # A veto always applies; only the log line is throttled.
90
- handle_error(bundle_name, basename, on_error, fail_closed, event, e) if fail_closed || log_failure.call
91
- end
92
- end
83
+ policy = if fail_closed then :deny
84
+ elsif on_error == "log" then :log
85
+ else :skip
86
+ end
87
+ label = "#{fail_closed ? "guardrail " : ""}#{basename} (bundle #{bundle_name})"
88
+ handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
89
+ fields: { bundle: bundle_name }) { |event| plugin.call(event) }
90
+ registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority, &handler)
93
91
  loaded += 1
94
92
  rescue Exception => e
95
93
  Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
@@ -119,10 +117,9 @@ module Samagotchi
119
117
  ns = namespace_for(bundle_name)
120
118
  content = File.read(file)
121
119
  ns.module_eval(content, file, 1)
122
- class_name = File.basename(basename, ".rb").split("_").map(&:capitalize).join
123
- klass = ns.const_get(class_name, false)
120
+ klass = Hooks.class_for(basename, ns)
124
121
  instance = Hooks.build_plugin(klass, settings)
125
- raise ArgumentError, "plugin #{class_name} does not respond to #call" unless instance.respond_to?(:call)
122
+ raise ArgumentError, "plugin #{klass.name.split("::").last} does not respond to #call" unless instance.respond_to?(:call)
126
123
  instance
127
124
  end
128
125
 
@@ -141,20 +138,6 @@ module Samagotchi
141
138
  Samagotchi::Bundles.const_set(ns_name.to_sym, Module.new)
142
139
  end
143
140
  end
144
-
145
- # Apply the per-hook on_error policy when a plugin raises.
146
- def handle_error(bundle_name, basename, on_error, fail_closed, event, error)
147
- if fail_closed && event.is_a?(Hash)
148
- event[:blocked] = true
149
- reason = "guardrail #{basename} (bundle #{bundle_name}) failed: #{error.class}: #{error.message}"
150
- event[:guardrail]&.deny!(reason, rule: "guardrail-load", source: "core", decided_by: "core")
151
- existing = event[:block_reason].to_s
152
- event[:block_reason] = existing.empty? ? reason : "#{existing}; #{reason}"
153
- elsif on_error == "log"
154
- Log.warn(:hooks, "bundle_hook_failed", echo: "[samagotchi:hooks] #{basename} (bundle #{bundle_name}) failed: #{error.class}: #{error.message}", bundle: bundle_name, hook: basename.to_s, error: error.class.name)
155
- end
156
- # "skip" (and fail_closed on non-veto events) is silent.
157
- end
158
141
  end
159
142
  end
160
143
  end
@@ -41,6 +41,15 @@ module Samagotchi
41
41
  File.join(ConfigFile.config_dir(env: env), "hooks", "")
42
42
  end
43
43
 
44
+ # The hooks folder: config.yml's hooks.hooks_dir, or the default,
45
+ # "~" expanded.
46
+ # @param data [Hash, nil] the parsed config.yml (read when not given)
47
+ def hooks_dir(env = ENV, data: ConfigFile.read_yaml(path: ConfigFile.global_path(env: env)))
48
+ section = data.is_a?(Hash) ? data["hooks"] : nil
49
+ configured = section.is_a?(Hash) ? section["hooks_dir"] : nil
50
+ expand_path(configured || default_hooks_dir(env), env)
51
+ end
52
+
44
53
  # Load hooks from a config hash and return a Registry with registered plugins.
45
54
  #
46
55
  # @param config_hash [Hash, nil] the hooks section from config.yml
@@ -52,7 +61,7 @@ module Samagotchi
52
61
  return Hooks::Registry.new unless config_hash&.key?("hooks")
53
62
 
54
63
  hooks_config = config_hash["hooks"]
55
- hooks_dir = expand_path(hooks_config["hooks_dir"] || default_hooks_dir(env), env)
64
+ hooks_dir = hooks_dir(env, data: config_hash)
56
65
 
57
66
  registry = Hooks::Registry.new
58
67
  definitions = parse_definitions(hooks_config)
@@ -60,20 +69,10 @@ module Samagotchi
60
69
  definitions.each do |defn|
61
70
  begin
62
71
  plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
63
- log_failure = Hooks.failure_log_gate(defn[:event_type])
64
72
  # Persistent: config hooks must fire on every turn, not be wiped
65
73
  # by Engine#run_turn's per-turn clear_hooks after turn 1.
66
- registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)") do |event|
67
- begin
68
- plugin.call(event)
69
- rescue StandardError => e
70
- if defn[:required] && defn[:event_type] == "before_tool_call"
71
- deny_for_raise(event, defn[:path], e)
72
- else
73
- handle_error(defn[:on_error] || "skip", defn[:path], e) if log_failure.call
74
- end
75
- end
76
- end
74
+ registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)",
75
+ &wrapped(plugin, defn))
77
76
  rescue ScriptError, StandardError => e
78
77
  # ScriptError: a SyntaxError (or LoadError) from `require`.
79
78
  Log.error(:hooks, "hook_load_failed", echo: "[samagotchi:hooks] hook #{defn[:path]} failed to load: #{e.class}: #{e.message}", hook: defn[:path].to_s, error: e.class.name)
@@ -130,33 +129,24 @@ module Samagotchi
130
129
 
131
130
  @plugin_cache[[full_path, settings]] ||= begin
132
131
  require full_path
133
- class_name = File.basename(path, ".rb").split("_").map(&:capitalize).join
134
- klass = Object.const_get(class_name)
132
+ klass = Hooks.class_for(path)
135
133
  instance = Hooks.build_plugin(klass, settings)
136
134
  # Validate that the instance responds to #call
137
- raise ArgumentError, "Plugin #{class_name} does not respond to #call" unless instance.respond_to?(:call)
135
+ raise ArgumentError, "Plugin #{klass.name} does not respond to #call" unless instance.respond_to?(:call)
138
136
  instance
139
137
  end
140
138
  end
141
139
 
142
- # A required before_tool_call hook that raises denies the call.
143
- def self.deny_for_raise(event, hook_path, error)
144
- return unless event.is_a?(Hash)
145
-
146
- reason = "required hook #{hook_path} raised #{error.class}: #{error.message}"
147
- event[:guardrail]&.deny!(reason, rule: "guardrail-load", source: "core", decided_by: "core")
148
- event[:blocked] = true
149
- event[:block_reason] ||= reason
150
- end
151
-
152
- # Handle a hook error based on the on_error config.
153
- def self.handle_error(on_error, hook_path, error = nil)
154
- case on_error
155
- when "log"
156
- Log.warn(:hooks, "hook_failed", echo: "[samagotchi:hook] #{error ? "#{error.class}: #{error.message}" : "hook failed"} (#{hook_path})", hook: hook_path.to_s, error: error&.class&.name)
157
- when "skip"
158
- # Silent — just skip
140
+ # The plugin's #call under the entry's error policy: a required
141
+ # before_tool_call hook that raises denies the call; otherwise
142
+ # on_error: log warns and skip is silent.
143
+ def self.wrapped(plugin, defn)
144
+ if defn[:required] && defn[:event_type] == "before_tool_call"
145
+ policy, label = :deny, "required hook #{defn[:path]}"
146
+ else
147
+ policy, label = (defn[:on_error] == "log" ? :log : :skip), "#{defn[:path]} (config)"
159
148
  end
149
+ Hooks.wrap(label: label, event: defn[:event_type].to_sym, policy: policy) { |event| plugin.call(event) }
160
150
  end
161
151
  end
162
152
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "../log"
4
5
 
5
6
  module Samagotchi
6
7
  module Hooks
@@ -36,6 +37,46 @@ module Samagotchi
36
37
  end
37
38
  end
38
39
 
40
+ # A hook file's class: the PascalCase of its basename (my_hook.rb →
41
+ # MyHook), looked up in +namespace+ (a bundle's module; Object for a
42
+ # config hook).
43
+ # @return [Class]
44
+ def self.class_for(file, namespace = Object)
45
+ namespace.const_get(File.basename(file.to_s, ".rb").split("_").map(&:capitalize).join, false)
46
+ end
47
+
48
+ # A hook's block wrapped in its error policy: what a loader registers.
49
+ # When the block raises,
50
+ # :deny denies the call (event[:guardrail], the Gate's Verdict),
51
+ # whatever was raised: a fail-closed before_tool_call guardrail.
52
+ # The Gate folds the verdict into event[:blocked] and
53
+ # event[:block_reason] for the hooks after it.
54
+ # :log warns (a stream hook at most once a minute, failure_log_gate)
55
+ # :skip is silent
56
+ # :log and :skip rescue StandardError and ScriptError.
57
+ # @param label [String] the hook, for the deny reason and the warning
58
+ # @param event [Symbol] the hook's event
59
+ # @param log [Array(Symbol, String)] the warning's tag and record name
60
+ # @param echo [Boolean] the warning also goes to stderr
61
+ # @param fields [Hash] more fields for the warning's record
62
+ def self.wrap(label:, event:, policy:, log: [:hooks, "hook_failed"], echo: true, fields: {}, &block)
63
+ log_failure = failure_log_gate(event)
64
+ lambda do |payload|
65
+ block.call(payload)
66
+ rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
67
+ raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
68
+
69
+ failed = "#{e.class}: #{e.message}"
70
+ if policy == :deny
71
+ payload[:guardrail]&.deny!("#{label} raised #{failed}", rule: "guardrail-load", source: "core", decided_by: "core") if payload.is_a?(Hash)
72
+ elsif policy == :log && log_failure.call
73
+ text = "#{label} failed: #{failed}"
74
+ Log.warn(log[0], log[1], echo: echo ? "[samagotchi:hooks] #{text}" : nil, hook: label, event: event.to_s,
75
+ error: e.class.name, msg: text, **fields)
76
+ end
77
+ end
78
+ end
79
+
39
80
  # What a hook can do beyond reading its event: the Engine's
40
81
  # callables, each given the hook's label. +notify+ takes
41
82
  # (text:, level:, hook:) and shows one line to the user; +ask_user+
@@ -26,8 +26,5 @@ module Samagotchi
26
26
  # event also has context: and targets: (docs/guardrails.md)
27
27
  # :after_tool_call — after tool execution, before result injection
28
28
  module Hooks
29
- REGISTRY_CLASS = Registry
30
- LOADER_CLASS = Loader
31
- BUNDLE_LOADER_CLASS = BundleLoader
32
29
  end
33
30
  end
@@ -77,6 +77,7 @@ module Samagotchi
77
77
  # @param clock [#call, nil] monotonic seconds (specs)
78
78
  def initialize(hosts_config: nil, env: ENV, client_override: nil, clock: nil)
79
79
  @client_override = client_override
80
+ @env = env
80
81
  @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
81
82
  @adapters = {}
82
83
  @host_lists = {}
@@ -133,6 +134,20 @@ module Samagotchi
133
134
  ConfigFile.parse_host_qualified_model(raw, hosts: @entries)
134
135
  end
135
136
 
137
+ # The names a models: entry may be under, in the order they are looked
138
+ # up: +typed+ as given (maybe an alias), its alias's target, +resolved+
139
+ # (what the model became, e.g. the default for a blank one), the part
140
+ # after a host prefix, and the bare model the host is asked for.
141
+ # Thinking, SamplingSettings, VisionSupport and ModelProfile all take these.
142
+ # @param target [ModelTarget, nil] the resolved model's target (resolved when not given)
143
+ # @return [Array<String>]
144
+ def lookup_names(typed, resolved: nil, target: nil)
145
+ aliased = ConfigFile.resolve_model_alias(typed, env: @env)
146
+ target ||= resolve(resolved || aliased)
147
+ [typed, aliased, resolved, parse_qualified_model(typed).last, target.bare_model]
148
+ .map { |name| name.to_s.strip }.reject(&:empty?).uniq
149
+ end
150
+
136
151
  # Resolve model string (already alias-resolved, may be qualified) to a HostEntry.
137
152
  # If qualified explicitly, return that host. If unqualified, try cached model index,
138
153
  # else fallback to default host.
@@ -36,13 +36,6 @@ module Samagotchi
36
36
  @due_reminder_name = nil
37
37
  end
38
38
 
39
- # Get the currently pending due reminder name (for Engine to read).
40
- # Kept for backward compatibility.
41
- # Called by Engine#collect_due_reminders. Thread-safe.
42
- def due_reminder_name
43
- @mutex.synchronize { @due_reminder_name }
44
- end
45
-
46
39
  # Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
47
40
  # Thread-safe. Returns all due reminders, not just one.
48
41
  # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
@@ -197,6 +197,8 @@ module Samagotchi
197
197
  REF_RE = %r{\Aimages/[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z}
198
198
  MIME = { png: "image/png", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }.freeze
199
199
  EXT = { png: "png", jpeg: "jpg", gif: "gif", webp: "webp" }.freeze
200
+ # The most images one turn may carry (a client's refs, check_refs).
201
+ MAX_TURN_REFS = 20
200
202
  # Nothing larger is even read.
201
203
  MAX_SOURCE_BYTES = 50 * 1024 * 1024
202
204
 
@@ -251,6 +253,26 @@ module Samagotchi
251
253
  File.file?(path) && !File.symlink?(path)
252
254
  end
253
255
 
256
+ # A turn's images from a client (the web, a Bridge request) as
257
+ # [{file:, name:}], or a String saying what's wrong. Only refs to files
258
+ # already in the session's images/ pass (an upload): never a path, so no
259
+ # client can make the worker read a file.
260
+ def self.check_refs(session_dir, raw)
261
+ return [] if raw.nil?
262
+ return "images must be a list" unless raw.is_a?(Array)
263
+ return "at most #{MAX_TURN_REFS} images" if raw.size > MAX_TURN_REFS
264
+
265
+ raw.map do |image|
266
+ return "each image must be {file:, name:}" unless image.is_a?(Hash)
267
+
268
+ ref = symbolize(image)
269
+ return "images are refs to uploaded files, not paths" if ref.key?(:path)
270
+ return "unknown image #{ref[:file].to_s[0, 80]}" unless valid_ref?(session_dir, ref)
271
+
272
+ { file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
273
+ end
274
+ end
275
+
254
276
  # Messages seeded into another session (a plugin's ctx.sessions.fork):
255
277
  # each image ref's file is copied from +from+ into +to+ (both session
256
278
  # dirs; no +from+ copies none). A ref whose file isn't there is dropped, and its message says
@@ -141,8 +141,8 @@ module Samagotchi
141
141
  # Run the conversation loop and return the final model response plus
142
142
  # resumable conversation state when execution stops at max_iterations.
143
143
  #
144
- # @param messages [Array<Hash>, Result] conversation so far ({role:, content:})
145
- # or a previous Result to resume
144
+ # @param messages [Array<Hash>] conversation so far ({role:, content:});
145
+ # a stopped run resumes from its Result#conversation
146
146
  # @param max_iterations [Integer] safety cap on tool-call rounds
147
147
  # @param on_stream_event [Proc, nil] optional callback for generation events
148
148
  # @param cancel_controller [CancellationController, nil] optional cancellation source
@@ -170,7 +170,6 @@ module Samagotchi
170
170
  empty_retries = 0
171
171
  empty_retry_limit = EmptyAnswerRetry.limit
172
172
  @retry_generation = false
173
- stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
174
173
  # Qwen with thinking off: an empty thought after the cue, so the model
175
174
  # answers at once. Kept in the turn's model messages, so each tool-loop
176
175
  # prompt starts with what the server already has cached.
@@ -192,6 +191,9 @@ module Samagotchi
192
191
  conversation << line
193
192
  prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
194
193
  end
194
+ # Each generation splits its own stream: one that ended inside a
195
+ # thought or a tool call doesn't leave the next one "inside" it.
196
+ stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
195
197
  emit_stream_event(
196
198
  on_stream_event,
197
199
  type: :generation_started,
@@ -235,6 +237,7 @@ module Samagotchi
235
237
  type: :generation_chunk,
236
238
  iteration: iteration_index + 1,
237
239
  content: chunk[:content],
240
+ text: split[:text],
238
241
  thinking: split[:thinking],
239
242
  payload: chunk[:payload]
240
243
  )
@@ -263,9 +266,8 @@ module Samagotchi
263
266
  ""
264
267
  end
265
268
  if cut
266
- # The cut stream may have stopped mid-block, and its visible text
267
- # goes with it: a fresh splitter, the buffer as it was before.
268
- stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
269
+ # The cut stream's visible text goes with it: the buffer as it was
270
+ # before (the next generation gets a fresh splitter).
269
271
  partial_assistant_buffer.slice!(buffer_mark..)
270
272
  emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
271
273
  content_length: 0, thinking_chars: streamed_thinking,
@@ -660,8 +662,8 @@ module Samagotchi
660
662
  private
661
663
 
662
664
  # The generation's thinking: what the stream split into the thinking
663
- # lane (Qwen), else what the profile's thought blocks hold (Gemma's
664
- # stream isn't split).
665
+ # lane, else what the profile's thought blocks hold (an empty thought, a
666
+ # bare Gemma <|think|> cue, a response that wasn't streamed).
665
667
  def thinking_chars(response, streamed)
666
668
  return streamed if streamed.positive?
667
669
 
@@ -687,14 +689,8 @@ module Samagotchi
687
689
  end
688
690
  end
689
691
 
690
- def prepare_conversation(messages)
691
- if messages.is_a?(Result)
692
- duplicate_conversation(messages.conversation)
693
- else
694
- # Standard multi-turn compliance: never pass prior raw thought blocks.
695
- sanitize_history(messages)
696
- end
697
- end
692
+ # Standard multi-turn compliance: never pass prior raw thought blocks.
693
+ def prepare_conversation(messages) = sanitize_history(messages)
698
694
 
699
695
  def duplicate_conversation(messages)
700
696
  messages.map(&:dup)
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "socket"
4
3
  require_relative "session"
5
4
  require_relative "version"
6
5
  require_relative "worker_sidecar"
@@ -12,7 +11,6 @@ module Samagotchi
12
11
  # older one names none) and a `chi web` on its port (/api/info). Read-only:
13
12
  # a dead worker's sidecar is left for the next client to clean up.
14
13
  module LiveVersions
15
- PROBE_TIMEOUT = 0.2
16
14
  WEB_TIMEOUT = 0.5
17
15
 
18
16
  # version is nil for a sidecar written before sidecars carried one.
@@ -24,8 +22,8 @@ module Samagotchi
24
22
  def workers(state_dir: Session.default_state_dir)
25
23
  Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
26
24
  session_dir = File.dirname(path)
27
- sidecar = WorkerSidecar.read(session_dir)
28
- next unless sidecar && listening?(sidecar.port)
25
+ sidecar = WorkerSidecar.live(session_dir, unlink: false)
26
+ next unless sidecar
29
27
 
30
28
  Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
31
29
  end
@@ -48,14 +46,5 @@ module Samagotchi
48
46
  info = Web::InfoProbe.call(host, port, timeout: timeout)
49
47
  info.is_a?(Hash) ? info : nil
50
48
  end
51
-
52
- def listening?(port, host: "127.0.0.1")
53
- return false unless port.positive?
54
-
55
- Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
56
- true
57
- rescue StandardError
58
- false
59
- end
60
49
  end
61
50
  end