samagotchi 0.10.1 → 0.12.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -1
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ require_relative "line_reader"
6
+
7
+ module Samagotchi
8
+ class TerminalUI
9
+ # Prepended to Reline::LineEditor: the first ↑ of a history walk at the
10
+ # main prompt runs the refresh first, so lines other processes (the web,
11
+ # another TUI) added to the shared history since the prompt opened are
12
+ # in the ring it walks. Set per read with .with_refresh; any other read
13
+ # (a question, a continue prompt) gets Reline's own ↑.
14
+ #
15
+ # Kept apart from RelineSeam, whose supported? is all-or-nothing: drift
16
+ # here only turns this hook off. It overrides two private methods of
17
+ # reline 0.6.x (the Gemfile pins it), both with Reline's signature
18
+ # (key, arg: 1), which Reline's dispatch looks at for an arg: keyword:
19
+ # - ed_prev_history: ↑, Ctrl-P, vi command k and -.
20
+ # - previous_history: its alias, which inputrc's previous-history binds.
21
+ # It reads two ivars: @history_pointer (nil while not walking the
22
+ # history) and @line_index (the cursor's line in a multi-line buffer;
23
+ # ↑ off line 0 only moves up a line). Without them it is plain Reline.
24
+ # A failing refresh leaves plain ↑; LineReader's Reprompt and Stop go on.
25
+ module RelineHistoryHook
26
+ # Reline's methods the hook overrides, with their arities.
27
+ METHODS = { ed_prev_history: -2, previous_history: -2 }.freeze
28
+
29
+ class << self
30
+ # @return [Boolean] this Reline has every method the hook overrides
31
+ def supported?
32
+ METHODS.all? { |name, arity| reline_method(name)&.arity == arity }
33
+ end
34
+
35
+ def install
36
+ Reline::LineEditor.prepend(self) unless Reline::LineEditor.include?(self)
37
+ end
38
+
39
+ # Run a read (on this thread) whose first ↑ of a walk calls
40
+ # +refresh+ first.
41
+ # @param refresh [#call, nil]
42
+ def with_refresh(refresh)
43
+ was = Thread.current[:samagotchi_reline_history_refresh]
44
+ Thread.current[:samagotchi_reline_history_refresh] = refresh
45
+ yield
46
+ ensure
47
+ Thread.current[:samagotchi_reline_history_refresh] = was
48
+ end
49
+
50
+ # @return [#call, nil] the refresh of the read on this thread
51
+ def refresh = Thread.current[:samagotchi_reline_history_refresh]
52
+
53
+ private
54
+
55
+ # Reline's own implementation, past this module once it is prepended.
56
+ def reline_method(name)
57
+ method = Reline::LineEditor.instance_method(name)
58
+ method = method.super_method while method&.owner == self
59
+ method
60
+ rescue NameError
61
+ nil
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def ed_prev_history(key, arg: 1)
68
+ refresh_history_before_walk
69
+ super
70
+ end
71
+
72
+ def previous_history(key, arg: 1)
73
+ refresh_history_before_walk
74
+ super
75
+ end
76
+
77
+ # Only at the start of a walk: appending to Reline::HISTORY mid-walk
78
+ # would shift what the pointer means.
79
+ def refresh_history_before_walk
80
+ refresh = RelineHistoryHook.refresh
81
+ return unless refresh
82
+ return unless instance_variable_defined?(:@history_pointer) && instance_variable_defined?(:@line_index)
83
+ return unless @history_pointer.nil? && @line_index.zero?
84
+
85
+ refresh.call
86
+ rescue LineReader::Reprompt, LineReader::Stop
87
+ raise
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end
@@ -12,7 +12,6 @@ require_relative "context_note"
12
12
  require_relative "cancellation_controller"
13
13
  require_relative "llm/errors"
14
14
  require_relative "host_registry"
15
- require_relative "kernel_loop"
16
15
  require_relative "session"
17
16
  require_relative "owner_lock"
18
17
  require_relative "engine"
@@ -84,8 +83,8 @@ module Samagotchi
84
83
  @scratch = scratch
85
84
  @prompt = prompt
86
85
  @default_model_name = ModelProfile.required_model_name(nil)
87
- aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
88
- flag_model_name = aliased_model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(aliased_model_name)
86
+ # As typed: the Engine applies an alias where it resolves the model.
87
+ flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
89
88
  @effective_model_name = flag_model_name || @default_model_name
90
89
  @host_registry = host_registry || Samagotchi::HostRegistry.new
91
90
  # An injected client (specs) stands in for every host's client.
@@ -106,6 +105,7 @@ module Samagotchi
106
105
  @effective_model_name = flag_model_name
107
106
  else
108
107
  @effective_model_name = @resume_session.model_name.to_s.strip.empty? ? @default_model_name : @resume_session.model_name
108
+ @resumed_model_typed = @resume_session.model_typed
109
109
  end
110
110
  # Re-resolve client after resume may change effective model
111
111
  @client = @host_registry.resolve(@effective_model_name).client
@@ -113,9 +113,6 @@ module Samagotchi
113
113
  # The model it starts on names a configured host (a bad default.model
114
114
  # doesn't matter when --model or the session picks another).
115
115
  ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
116
- # Only a caller's profile goes in; otherwise the Engine resolves one
117
- # for the effective model and hands it to the kernel.
118
- @kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
119
116
  @no_default_input = no_default_input
120
117
  @non_interactive = non_interactive
121
118
  # --memory and --mute, with a resumed session's own lists first: a
@@ -156,7 +153,6 @@ module Samagotchi
156
153
  model_name: nil,
157
154
  memories: @requested_memories,
158
155
  muted_memories: @muted_memory_names,
159
- kernel: @kernel,
160
156
  recap: scratch ? nil : recap_config,
161
157
  scratch: scratch,
162
158
  reminders: {
@@ -180,7 +176,7 @@ module Samagotchi
180
176
  registry: @engine.command_registry)
181
177
  # Runtime --model flag or resumed session: switch the Engine (client,
182
178
  # kernel profile) without persisting the default.
183
- @engine.switch_model!(@effective_model_name) if @effective_model_name != @default_model_name
179
+ @engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if @effective_model_name != @default_model_name
184
180
  # Render an idle session-recap via the cursor-safe background writer; the
185
181
  # detector itself is Engine-owned (see Engine#recap) and opt-in.
186
182
  @recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
@@ -217,14 +213,14 @@ module Samagotchi
217
213
  # nothing and return (no transient session, no banner).
218
214
  return if @non_interactive && @prompt.nil?
219
215
 
220
- session = @resume_session || Session.new_session(
216
+ session = @resume_session || @engine.store_model!(Session.new_session(
221
217
  mode: "assist",
222
218
  model_name: @effective_model_name,
223
219
  working_directory: Dir.pwd,
224
220
  preloaded_memory_names: @requested_memories,
225
221
  muted_memory_names: @muted_memory_names,
226
222
  scratch: @scratch
227
- )
223
+ ))
228
224
  claim_session!(session.id) unless @owner_lock
229
225
  # Saved at once, so a process killed before its first turn leaves a
230
226
  # file the sweep knows to delete.
@@ -244,7 +240,9 @@ module Samagotchi
244
240
  cancel_controller: nil,
245
241
  images: ImageInput.extract(@prompt)
246
242
  )
247
- if empty_answer?(result)
243
+ # No answer (the empty-answer retries used up): no text, or the chat
244
+ # loop's placeholder (LLM::ModelResult#empty_answer?).
245
+ if result.empty_answer?
248
246
  # Nothing to print: say so on stderr and fail, so a script that
249
247
  # pipes the answer doesn't take the silence for one.
250
248
  session.save
@@ -280,15 +278,6 @@ module Samagotchi
280
278
 
281
279
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
282
280
 
283
- # A -p --non-interactive turn that ended with no answer (the empty-answer
284
- # retries used up): no text, or the chat loop's placeholder.
285
- def empty_answer?(result)
286
- return false if result.respond_to?(:canceled?) && result.canceled?
287
- return false if result.respond_to?(:resumable?) && result.resumable?
288
-
289
- result.output.to_s.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?)
290
- end
291
-
292
281
  # Delete the scratch session, the last thing the REPL does. Quiet after
293
282
  # a one-shot or on the way out of an error or a signal.
294
283
  def delete_scratch(session, quiet:)
@@ -668,7 +657,7 @@ module Samagotchi
668
657
  # After a prompt or continue turn: TurnFlow keeps the checkpoint and the
669
658
  # continue offer; the REPL saves and tells the user.
670
659
  def finish_turn(session, result, continue:)
671
- if result.respond_to?(:canceled?) && result.canceled?
660
+ if result.canceled?
672
661
  outcome = @turn_flow.after_turn(result, continue: continue)
673
662
  # A cancelled continue is back where it started, the offer still open.
674
663
  return if outcome == :continue_cancelled
@@ -679,7 +668,7 @@ module Samagotchi
679
668
  # user's next message continues from it. !rollback restores the
680
669
  # pre-turn checkpoint for an explicit full discard (the renderer's
681
670
  # hint under :turn_canceled says so).
682
- save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
671
+ save_session(session) if result.conversation.is_a?(Array)
683
672
  return
684
673
  end
685
674
 
@@ -691,7 +680,7 @@ module Samagotchi
691
680
  end
692
681
 
693
682
  def save_session(session)
694
- session.model_name = @effective_model_name
683
+ @engine.store_model!(session)
695
684
  session.save
696
685
  end
697
686
 
@@ -757,10 +746,10 @@ module Samagotchi
757
746
  ensure
758
747
  @surface.clear_slot(:hints)
759
748
  end
760
- canceled = result.respond_to?(:canceled?) && result.canceled?
749
+ canceled = result&.canceled?
761
750
  if result && !canceled
762
- session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
763
- session.model_name = @effective_model_name
751
+ session.messages = result.conversation if result.conversation.is_a?(Array)
752
+ @engine.store_model!(session)
764
753
  session.save
765
754
  end
766
755
  # The synthetic turn is activity: the next reminder waits a full
@@ -995,15 +984,7 @@ module Samagotchi
995
984
  end
996
985
 
997
986
  def cancelled_result_from(messages, reason:)
998
- KernelLoop::Result.new(
999
- output: "",
1000
- conversation: clone_messages(messages),
1001
- exhausted: false,
1002
- pending_tool_calls: false,
1003
- tool_activity: [],
1004
- canceled: true,
1005
- cancellation_reason: reason
1006
- )
987
+ LLM::ModelResult.new(text: "", conversation: clone_messages(messages), canceled: true, cancellation_reason: reason)
1007
988
  end
1008
989
 
1009
990
  # The status row under the prompt, from the Engine's state (what a
@@ -65,7 +65,7 @@ module Samagotchi
65
65
  key, value = ConfigFile.model_setting(names, :thinking, models: models)
66
66
  return [value, "models: #{key}"] if key
67
67
 
68
- host = target.entry.respond_to?(:thinking) ? target.entry.thinking : nil
68
+ host = target.entry.thinking
69
69
  return [host, "hosts.#{target.entry.name}"] if host
70
70
  return [global, "thinking.level"] if global
71
71
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A tool batch and the conversation entries its results become, for both
5
+ # loops. The calls run through ToolRunner between the dispatch events;
6
+ # what the model gets back is per format: the native loop answers a batch
7
+ # with ONE tool_response (#joined: the full outputs), the chat loop each
8
+ # call with its own, paired by tool_call_id (#single: the capped output,
9
+ # D-P2-6).
10
+ module ToolResponse
11
+ SEPARATOR = "\n\n---\n\n"
12
+ # Saved with a result for the web's reload (a plugin tool's params line
13
+ # and label, an edit/write's diff); the prompt never reads them.
14
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs].freeze
15
+
16
+ module_function
17
+
18
+ # Runs +calls+ in order (ToolRunner#run each), yielding each run and its
19
+ # index as it finishes. +emit+ takes the dispatch events, the runner's
20
+ # own go to +on_stream_event+.
21
+ # @return [Array<Hash>] the runs
22
+ def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
23
+ emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
24
+ runs = calls.each_with_index.map do |call, index|
25
+ run = runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
26
+ on_stream_event: on_stream_event, max_tool_output_chars: cap)
27
+ yield run, index if block_given?
28
+ run
29
+ end
30
+ emit.call({ type: :tool_dispatch_completed, iteration: iteration, call_count: calls.length })
31
+ runs
32
+ end
33
+
34
+ # The runs' activity, for the turn's tool list (none for a call whose
35
+ # dispatcher raised).
36
+ def activities(runs)
37
+ runs.filter_map { |run| run[:activity] }
38
+ end
39
+
40
+ # The native loop's entry for a batch: the outputs joined, every image
41
+ # in call order with how many each call returned (the web's reload puts
42
+ # each on its own tool row), and the saved-only fields one per call.
43
+ def joined(runs)
44
+ images = runs.flat_map { |run| Array(run[:images]) }
45
+ entry = { role: "tool_response", content: runs.map { |run| run[:output] }.join(SEPARATOR) }
46
+ unless images.empty?
47
+ entry[:images] = images
48
+ entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
49
+ end
50
+ saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
51
+ tool_diffs: runs.map { |run| run[:diff] } }
52
+ saved.each { |key, values| entry[key] = values if values.any? }
53
+ entry
54
+ end
55
+
56
+ # The chat loop's entry for one call: its capped output, paired with the
57
+ # call by +tool_call_id+.
58
+ def single(run, tool_call_id:)
59
+ entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call_id }
60
+ entry[:images] = run[:images] if run[:images]&.any?
61
+ entry[:tool_params] = run[:shown_params] if run[:shown_params]
62
+ entry[:tool_labels] = run[:shown_label] if run[:shown_label]
63
+ entry[:tool_diffs] = run[:diff] if run[:diff]
64
+ entry
65
+ end
66
+ end
67
+ end
@@ -80,7 +80,12 @@ module Samagotchi
80
80
  capped = output[0, max_tool_output_chars]
81
81
  end
82
82
 
83
- fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped })
83
+ # The call's outcome as its activity line has it (worked out from the
84
+ # full output: ok, error, blocked, stopped); a dispatcher that raised
85
+ # left none, an error.
86
+ status = result[:activity].is_a?(Hash) ? result[:activity][:status] : nil
87
+ fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
88
+ status: status || "error" })
84
89
  completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
85
90
  tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
86
91
  completed[:images] = images if images&.any?
@@ -141,7 +146,7 @@ module Samagotchi
141
146
  # says the image is attached, that line replaces the text.
142
147
  # @return [Array(Hash, Array<Hash>)] the result and its refs
143
148
  def attach_images(call, result)
144
- vision = @kernel.vision if @kernel.respond_to?(:vision)
149
+ vision = @kernel.turn_settings.vision
145
150
  reason = if vision.nil? then "images can't be attached here"
146
151
  elsif !vision.sendable? then ImagePlan::CANT_SEE
147
152
  end
@@ -187,7 +192,7 @@ module Samagotchi
187
192
  end
188
193
 
189
194
  # The kernel's tools, for the activity line of a tool that isn't built in.
190
- def tools = @kernel.respond_to?(:tools) ? @kernel.tools : nil
195
+ def tools = @kernel.tools
191
196
 
192
197
  # A tool the registry has from a plugin (not core, not unknown).
193
198
  def plugin_tool?(name)
@@ -201,8 +206,7 @@ module Samagotchi
201
206
  # The Engine sets the kernel's gate (its context, later the approval
202
207
  # flow); a bare kernel (specs) gets one that only runs the hooks.
203
208
  def gate
204
- given = @kernel.guardrail_gate if @kernel.respond_to?(:guardrail_gate)
205
- given || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks if @kernel.respond_to?(:hooks) }, tools_lookup: -> { tools }))
209
+ @kernel.guardrail_gate || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks }, tools_lookup: -> { tools }))
206
210
  end
207
211
 
208
212
  # A gate that fails denies the call (fail closed).
@@ -249,8 +253,7 @@ module Samagotchi
249
253
 
250
254
  # A failing hook must not break the turn.
251
255
  def fire(name, event)
252
- hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
253
- hooks&.fire(name, event)
256
+ @kernel.hooks&.fire(name, event)
254
257
  rescue StandardError
255
258
  nil
256
259
  end
@@ -103,7 +103,7 @@ module Samagotchi
103
103
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
104
104
  def cancelled_proc(kctx)
105
105
  peers = kctx.peers
106
- -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
106
+ -> { !!peers&.cancelled? }
107
107
  end
108
108
 
109
109
  # @return [Registry] a new registry with the built-ins, in
@@ -117,12 +117,13 @@ module Samagotchi
117
117
  end
118
118
  private_class_method :follow_up
119
119
 
120
- # --model as the REPL reads it: an alias resolved, a host prefix kept.
121
- # No existence check: an unknown model fails the child's first turn.
120
+ # The model as typed (spawn_session stores its resolved ref), else the
121
+ # parent's. No existence check: an unknown model fails the child's
122
+ # first turn.
122
123
  def self.child_model(model, parent)
123
124
  return parent.model_name if model.to_s.strip.empty?
124
125
 
125
- ModelProfile.required_model_name(ConfigFile.resolve_model_alias(model.to_s.strip))
126
+ ModelProfile.required_model_name(model.to_s.strip)
126
127
  end
127
128
  private_class_method :child_model
128
129
 
@@ -43,7 +43,7 @@ module Samagotchi
43
43
  def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
44
44
  sd = peers.state_dir || Session.default_state_dir
45
45
  key = [peers.session_id, child_id]
46
- cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
46
+ cancelled = -> { peers.cancelled? }
47
47
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
48
48
  poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
49
49
  # The turn sent to is handed over: a later wait looks for a later one.
@@ -22,7 +22,8 @@ module Samagotchi
22
22
 
23
23
  SEPARATOR = "\n\n---\n\n"
24
24
 
25
- def self.call(entry_name, scope: nil, model_key: nil)
25
+ # @param fallback_model_key [String, nil] read when +model_key+ has no overlay
26
+ def self.call(entry_name, scope: nil, model_key: nil, fallback_model_key: nil)
26
27
  entry_name = entry_name.to_s.strip
27
28
  scope = normalize_scope(scope)
28
29
 
@@ -60,11 +61,12 @@ module Samagotchi
60
61
  if File.exist?(path)
61
62
  body = File.read(path)
62
63
  # Append model-specific overlay if key is provided and overlay exists.
63
- if model_key
64
- overlay_path = ModelOverlay.overlay_path_for(name, model_key, resolved_scope)
65
- if overlay_path && File.exist?(overlay_path)
66
- body += SEPARATOR + "Model-specific guidance (#{model_key}):\n" + File.read(overlay_path)
67
- end
64
+ [model_key, fallback_model_key].compact.each do |key|
65
+ overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
66
+ next unless overlay_path && File.exist?(overlay_path)
67
+
68
+ body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
69
+ break
68
70
  end
69
71
  results << body
70
72
  found = true
@@ -72,7 +72,7 @@ module Samagotchi
72
72
  # turn: its partial progress stays, !rollback can undo it) or
73
73
  # :continue_cancelled (back to before the continue; the offer stays)
74
74
  def after_turn(result, continue: false, no_interrupt: false)
75
- if result.respond_to?(:canceled?) && result.canceled?
75
+ if result.canceled?
76
76
  if continue
77
77
  restore(@continue_checkpoint)
78
78
  return :continue_cancelled
@@ -85,7 +85,7 @@ module Samagotchi
85
85
  return :cancelled
86
86
  end
87
87
 
88
- if result.respond_to?(:resumable?) && result.resumable?
88
+ if result.resumable?
89
89
  @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
90
90
  :continue_offered
91
91
  else
@@ -172,7 +172,7 @@ module Samagotchi
172
172
  end
173
173
 
174
174
  def conversation_of(result)
175
- result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
175
+ result.conversation if result.conversation.is_a?(Array)
176
176
  end
177
177
 
178
178
  def interrupted_turn_context(result)
@@ -198,7 +198,7 @@ module Samagotchi
198
198
  end
199
199
 
200
200
  def tool_trace(result)
201
- activities = result.respond_to?(:tool_activity) ? Array(result.tool_activity) : []
201
+ activities = Array(result.tool_activity)
202
202
  activities.last(SUMMARY_TOOLS_LIMIT).map do |activity|
203
203
  tool = activity[:tool].to_s.strip
204
204
  status = activity[:status].to_s.strip
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Samagotchi
6
+ # A session's turn as other threads see it: whether one runs, its cancel
7
+ # controller and event sink, the plugin steers waiting for its next
8
+ # boundary, and the activity clock the idle jobs read.
9
+ #
10
+ # The turn thread writes it (Engine#begin_turn, #release_turn); the idle
11
+ # scheduler, the bridge's request threads, plugin threads and the UIs
12
+ # read it. The lock is a leaf: nothing is called out while it is held,
13
+ # so any lock may be held around a call into it, and it is never held
14
+ # around anything else.
15
+ class TurnState
16
+ # @param clock [#call] monotonic seconds
17
+ def initialize(clock:)
18
+ @clock = clock
19
+ @lock = Monitor.new
20
+ @last_activity_at = clock.call
21
+ @activity_seq = 0
22
+ @running = false
23
+ @controller = nil
24
+ @sink = nil
25
+ @steers = []
26
+ end
27
+
28
+ # ── Activity clock ──────────────────────────────────────────────────
29
+
30
+ # Activity happened (user input, a finished turn): the clock moves to
31
+ # +now+ and the sequence advances, so the idle jobs re-arm.
32
+ # @param now [Float, nil] monotonic seconds; the clock's now by default
33
+ def record_activity(now = nil)
34
+ now = now ? now.to_f : @clock.call
35
+ @lock.synchronize do
36
+ @last_activity_at = now
37
+ @activity_seq += 1
38
+ end
39
+ end
40
+
41
+ # Start the idle window now without counting it as activity (the
42
+ # sequence stays): the Engine does once its plugins have loaded, so a
43
+ # slow plugin start doesn't shorten the first window.
44
+ def restart_clock!
45
+ now = @clock.call
46
+ @lock.synchronize { @last_activity_at = now }
47
+ end
48
+
49
+ # @return [Float] monotonic seconds of the last activity
50
+ def last_activity_at
51
+ @lock.synchronize { @last_activity_at }
52
+ end
53
+
54
+ # @return [Integer] how many times activity was recorded
55
+ def activity_seq
56
+ @lock.synchronize { @activity_seq }
57
+ end
58
+
59
+ # ── The turn ────────────────────────────────────────────────────────
60
+
61
+ # @return [Boolean] whether a turn runs
62
+ def running?
63
+ @lock.synchronize { @running }
64
+ end
65
+
66
+ # A turn begins: running, with its controller and sink, in one step.
67
+ def begin!(controller:, sink:)
68
+ @lock.synchronize do
69
+ @running = true
70
+ @controller = controller
71
+ @sink = sink
72
+ end
73
+ end
74
+
75
+ # The turn is over: not running, no controller, sink or steers.
76
+ # @return [Array<Hash>] the steers it never took ({text:, source:})
77
+ def finish!
78
+ @lock.synchronize do
79
+ @running = false
80
+ @controller = nil
81
+ @sink = nil
82
+ @steers.tap { @steers = [] }
83
+ end
84
+ end
85
+
86
+ # @return [CancellationController, nil] the running turn's controller
87
+ def controller
88
+ @lock.synchronize { @controller }
89
+ end
90
+
91
+ # Cancel the running turn, if any (outside the lock).
92
+ # @return [Boolean] whether a cancellation was triggered
93
+ def cancel!(reason)
94
+ ctrl = controller
95
+ return false unless ctrl
96
+
97
+ ctrl.cancel!(reason)
98
+ end
99
+
100
+ # @return [Array(Boolean, #call)] whether a turn runs, and its sink
101
+ def in_turn_sink
102
+ @lock.synchronize { [@running, @sink] }
103
+ end
104
+
105
+ # ── Steers ──────────────────────────────────────────────────────────
106
+
107
+ # Queue +text+ for the running turn's next boundary.
108
+ # @return [Boolean] whether it was queued (false: blank, or no turn)
109
+ def steer(text, source:)
110
+ text = text.to_s.strip
111
+ return false if text.empty?
112
+
113
+ @lock.synchronize do
114
+ return false unless @running
115
+
116
+ @steers << { text: text, source: source.to_s }
117
+ end
118
+ true
119
+ end
120
+
121
+ # @return [Array<Hash>] the steers queued so far ({text:, source:}), taken
122
+ def take_steers
123
+ @lock.synchronize { @steers.tap { @steers = [] } }
124
+ end
125
+ end
126
+ end