samagotchi 0.47.0 → 0.49.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.
@@ -57,16 +57,21 @@ module Samagotchi
57
57
  # @param wake [#call, nil] (parent id) wakes a parent with no worker;
58
58
  # nil: SessionManager.wake_for_report on a thread
59
59
  # @return [String, nil] the ring file, nil when none was written
60
+ # The parent is read fresh: a continue may have moved the child to the
61
+ # chain's new link (Session::PARENT_FILE) since its worker loaded it.
60
62
  def ring(child, why:, state_dir:, wake: nil)
61
63
  return nil if mode == "off"
62
- return nil unless child&.delegate? && child.parent_id
64
+ return nil unless child&.delegate?
65
+
66
+ parent_id = Session.parent_override(child.id, state_dir: state_dir) || child.parent_id
67
+ return nil unless parent_id
63
68
  # A deleted parent gets no orphan dir.
64
- return nil unless Session.exist?(child.parent_id, state_dir: state_dir)
69
+ return nil unless Session.exist?(parent_id, state_dir: state_dir)
65
70
 
66
- parent_dir = Session.session_dir(child.parent_id, state_dir: state_dir)
71
+ parent_dir = Session.session_dir(parent_id, state_dir: state_dir)
67
72
  path = SessionInbox.write_ring(parent_dir, child_id: child.id, why: why)
68
- Log.info(:worker, "delegate_rang", parent: child.parent_id[0, 8], why: why)
69
- wake_parent(child.parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
73
+ Log.info(:worker, "delegate_rang", parent: parent_id[0, 8], why: why)
74
+ wake_parent(parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
70
75
  path
71
76
  rescue StandardError => e
72
77
  Log.warn(:worker, "delegate_ring_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
@@ -129,10 +134,14 @@ module Samagotchi
129
134
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
130
135
  end
131
136
 
132
- def initialize(session_id:, state_dir:)
137
+ # @param predecessor [String, nil] the link this session continues
138
+ # (Session#continues): a child a continue moved here may have rung it
139
+ # just as it moved (#ring_files)
140
+ def initialize(session_id:, state_dir:, predecessor: nil)
133
141
  @session_id = session_id
134
142
  @state_dir = state_dir
135
143
  @session_dir = Session.session_dir(session_id, state_dir: state_dir)
144
+ @predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
136
145
  # child id → the Report this turn took for it (rings merged).
137
146
  @taken = {}
138
147
  end
@@ -142,7 +151,7 @@ module Samagotchi
142
151
  return false if ChildRing.mode == "off"
143
152
 
144
153
  taken = taken_rings
145
- SessionInbox.find_ring_files(@session_dir).any? { |file| !taken.include?(file) }
154
+ ring_files.any? { |file| !taken.include?(file) }
146
155
  end
147
156
 
148
157
  # The reports for rings this turn hasn't taken yet, recorded as taken.
@@ -153,7 +162,7 @@ module Samagotchi
153
162
  return [] if ChildRing.mode == "off"
154
163
 
155
164
  taken = taken_rings
156
- fresh = SessionInbox.find_ring_files(@session_dir).reject { |file| taken.include?(file) }
165
+ fresh = ring_files.reject { |file| taken.include?(file) }
157
166
  by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
158
167
  by_child.filter_map do |child_id, rings|
159
168
  report = child_id && report_for(child_id, rings)
@@ -193,6 +202,21 @@ module Samagotchi
193
202
  @taken.values.flat_map(&:rings)
194
203
  end
195
204
 
205
+ # The rings in children/, and the predecessor's rings from children a
206
+ # continue moved here (their parent.json names this session): a ring
207
+ # its child wrote as the move went on. Another child's ring there is
208
+ # the predecessor's, left alone.
209
+ def ring_files
210
+ own = SessionInbox.find_ring_files(@session_dir)
211
+ return own unless @predecessor_dir
212
+
213
+ strays = SessionInbox.find_ring_files(@predecessor_dir).select do |file|
214
+ child_id = SessionInbox.read_ring(file)&.dig(:child_id)
215
+ Session.valid_id?(child_id) && Session.parent_override(child_id, state_dir: @state_dir) == @session_id
216
+ end
217
+ own + strays
218
+ end
219
+
196
220
  # A second ring for a child taken earlier in this turn: read from where
197
221
  # the first report left the cursor, keeping the first one's +before+.
198
222
  def merge_taken(earlier, report)
@@ -2470,7 +2470,8 @@ module Samagotchi
2470
2470
  model_key: -> { @model_key },
2471
2471
  state_dir: -> { session_state_dir },
2472
2472
  scratch: -> { @scratch },
2473
- llm_context: -> { @session&.llm_context }
2473
+ llm_context: -> { @session&.llm_context },
2474
+ frontend: -> { interface }
2474
2475
  )
2475
2476
  end
2476
2477
 
@@ -40,7 +40,7 @@ module Samagotchi
40
40
  :system_prefix, :user_prefix, :assistant_prefix,
41
41
  :model_prefix,
42
42
  :stop_sequences, :tool_decl_format,
43
- :image_template
43
+ :image_template, :image_check_token
44
44
 
45
45
  def initialize(config)
46
46
  @name = config[:name]
@@ -65,10 +65,12 @@ module Samagotchi
65
65
  # How the chat template wraps an image's media marker ("%{marker}"),
66
66
  # or nil when chi doesn't know it yet (native images are refused).
67
67
  @image_template = config[:image_template]
68
+ # A token the server's chat template must contain for images to be
69
+ # sent (VisionSupport); defaults to the template's opening token.
70
+ @image_check_token = config[:image_check_token] || image_open_token
68
71
  end
69
72
 
70
- # The token the image template opens with, e.g. "<|vision_start|>": the
71
- # server's chat template must use it too (VisionSupport).
73
+ # The token the image template opens with, e.g. "<|vision_start|>".
72
74
  def image_open_token
73
75
  image_template&.split("%{marker}")&.first.to_s
74
76
  end
@@ -92,7 +94,12 @@ module Samagotchi
92
94
  assistant_prefix: "",
93
95
  model_prefix: "",
94
96
  stop_sequences: ["<turn|>", "<|tool_response>"],
95
- tool_decl_format: :gemma4
97
+ tool_decl_format: :gemma4,
98
+ # The bare marker: llama.cpp's mtmd wraps Gemma 4 images in
99
+ # <|image>…<image|> itself, as its chat path does (V13 spike). The
100
+ # template writes <|image|> for an image part; Gemma 3's doesn't.
101
+ image_template: "%{marker}",
102
+ image_check_token: "<|image|>"
96
103
  )
97
104
  end
98
105
 
@@ -25,9 +25,11 @@ module Samagotchi
25
25
  # model's memory overlay key (ctx.model, ctx.model_key).
26
26
  # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
27
27
  # (reason, label), each true when it acted on a running turn.
28
+ # +frontend+ returns the Engine's interface (:repl, :worker,
29
+ # :non_interactive), which ctx.frontend names for plugins.
28
30
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
29
31
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
30
- keyword_init: true)
32
+ :frontend, keyword_init: true)
31
33
 
32
34
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
33
35
  # sent nothing usable. The message says why, for the user.
@@ -103,6 +105,16 @@ module Samagotchi
103
105
  # Whether the session is a `chi scratch` one (deleted when it ends).
104
106
  def scratch? = !!@host.scratch&.call
105
107
 
108
+ # ctx.frontend's values, by the Engine's interface.
109
+ FRONTENDS = { repl: :repl, non_interactive: :one_shot, worker: :worker }.freeze
110
+
111
+ # What runs the session, read now (the host sets it after the plugins
112
+ # load): :repl (the REPL, -p without --non-interactive), :one_shot
113
+ # (-p --non-interactive: one turn, then exit) or :worker (a session
114
+ # worker: the web, an attached TUI). A host without one is :worker.
115
+ # @return [Symbol]
116
+ def frontend = FRONTENDS.fetch(@host.frontend&.call, :worker)
117
+
106
118
  # Whether the session is a delegate child (Session#delegate?): a task
107
119
  # another session handed over, not the user's own.
108
120
  def delegate? = !!saved_session&.delegate?
@@ -59,30 +59,45 @@ module Samagotchi
59
59
  [text + prefill_for(messages, profile, prefill), images]
60
60
  end
61
61
 
62
- def self.image_lines(items, profile, images)
63
- return "" if items.empty?
62
+ # A message's image lines: +text+ holds them all in order (what goes
63
+ # inside the message); +placeholders+ and +markers+ split the lines of
64
+ # images not sent from the sent ones' media markers, for Gemma's tool
65
+ # response, whose markers go after the block.
66
+ ImageLines = Data.define(:lines) do
67
+ def text = join(lines)
68
+ def placeholders = join(lines.reject(&:last))
69
+ def markers = join(lines.select(&:last))
70
+
71
+ private
72
+
73
+ def join(subset) = subset.empty? ? "" : "\n#{subset.map(&:first).join("\n")}"
74
+ end
64
75
 
76
+ def self.image_lines(items, profile, images)
65
77
  lines = items.each_with_index.map do |item, index|
66
- next item.placeholder unless item.sent?
67
- next ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE) unless profile.image_template
78
+ next [item.placeholder, false] unless item.sent?
79
+ next [ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE), false] unless profile.image_template
68
80
 
69
81
  images << item.data
70
- "[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
71
- "#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}"
82
+ ["[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
83
+ "#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}", true]
72
84
  end
73
- "\n#{lines.join("\n")}"
85
+ ImageLines.new(lines: lines)
74
86
  end
75
87
 
76
88
  def self.format_with_turn_markers(messages, profile, suffixes)
77
89
  last_user = messages.rindex { |m| m[:role] == "user" } || -1
78
90
  parts = messages.each_with_index.map do |m, index|
79
- content = prompt_content_for(m, profile) + suffixes[index]
91
+ lines = suffixes[index]
92
+ content = prompt_content_for(m, profile) + (m[:role] == "tool_response" ? lines.placeholders : lines.text)
80
93
  previous = index.positive? ? messages[index - 1][:role] : nil
81
94
  following = messages[index + 1]&.dig(:role)
82
95
  case m[:role]
83
96
  when "tool_response"
84
- # Inside the model's turn; closed when another role follows.
85
- tool_response_blocks(content, profile) +
97
+ # Inside the model's turn; closed when another role follows. Its
98
+ # sent images go after the blocks, outside the quoted value, as
99
+ # Gemma's chat template puts them; placeholder lines stay inside.
100
+ tool_response_blocks(content, profile) + lines.markers +
86
101
  (following && following != "tool_response" && following != "model" ? "#{profile.turn_end}\n" : "")
87
102
  when "model"
88
103
  content = strip_gemma_thought(content) if index < last_user && !content.include?(profile.tool_call_open)
@@ -136,7 +151,7 @@ module Samagotchi
136
151
  parts = []
137
152
 
138
153
  messages.each_with_index do |m, index|
139
- content = prompt_content_for(m, profile) + suffixes[index]
154
+ content = prompt_content_for(m, profile) + suffixes[index].text
140
155
  case m[:role]
141
156
  when "system"
142
157
  parts << "#{profile.system_prefix}#{content}<|im_end|>\n"
@@ -16,7 +16,10 @@ module Samagotchi
16
16
  ["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
17
17
  ["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
18
18
  ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
19
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
19
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE],
20
+ ["[[SAMAGOTCHI_LITERAL_IMAGE_SOFT]]", "<|image|>"],
21
+ ["[[SAMAGOTCHI_LITERAL_IMAGE_START]]", "<|image>"],
22
+ ["[[SAMAGOTCHI_LITERAL_IMAGE_END]]", "<image|>"]
20
23
  ].freeze
21
24
 
22
25
  QWEN_TOKEN_PAIRS = [
@@ -333,7 +333,12 @@ module Samagotchi
333
333
  false
334
334
  end
335
335
 
336
- def continued_words(session) = session.continues ? " (continues #{session.continues[0, 8]})" : ""
336
+ def continued_words(session)
337
+ return "" unless session.continues
338
+
339
+ moved = Array(session.moved_children).size
340
+ " (continues #{session.continues[0, 8]}#{"; #{moved} delegate#{"s" if moved != 1} moved" if moved.positive?})"
341
+ end
337
342
 
338
343
  # One existing session, then its next reply. The cursor and baseline
339
344
  # are taken before the message goes in, so neither an older reply nor a
@@ -32,6 +32,12 @@ module Samagotchi
32
32
  # resume removes it. A file from before the marker says "stopped" in
33
33
  # its status field, which reads the same.
34
34
  STOPPED_FILE = "stopped"
35
+ # A moved session's parent, <session dir>/parent.json ({parent_id, from,
36
+ # at}): a continue moving a delegate to the chain's new link
37
+ # (ChildMove) writes it instead of rewriting the session file its live
38
+ # worker may save a stale copy of; both load paths apply it, like the
39
+ # stop marker. Never removed: a later move overwrites it.
40
+ PARENT_FILE = "parent.json"
35
41
  # The memory a delegate child starts with (Tools::Delegate::CHILD_MEMORIES).
36
42
  DELEGATE_MEMORY = "system/delegated"
37
43
 
@@ -130,6 +136,10 @@ module Samagotchi
130
136
  # not saved.
131
137
  attr_accessor :model_warning
132
138
 
139
+ # The delegates a continue moved to this new link (ChildMove;
140
+ # SessionManager.continue_session sets it); not saved.
141
+ attr_accessor :moved_children
142
+
133
143
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
134
144
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
135
145
  first_preview: "", test_run: false, pending_question: nil,
@@ -258,6 +268,7 @@ module Samagotchi
258
268
 
259
269
  session = from_h(JSON.parse(File.read(path)))
260
270
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
271
+ session.parent_id = parent_override(session.id, state_dir: state_dir) || session.parent_id
261
272
  session
262
273
  rescue JSON::ParserError => e
263
274
  raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
@@ -386,6 +397,7 @@ module Samagotchi
386
397
 
387
398
  session = from_h(data, messages: false)
388
399
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
400
+ session.parent_id = parent_override(session.id, state_dir: File.dirname(path)) || session.parent_id
389
401
  session
390
402
  rescue JSON::ParserError, KeyError, SystemCallError
391
403
  nil
@@ -453,6 +465,29 @@ module Samagotchi
453
465
  File.exist?(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
454
466
  end
455
467
 
468
+ # Give +session_id+ a new parent (PARENT_FILE), +from+ the one it had.
469
+ def self.reparent(session_id, to:, from:, state_dir: default_state_dir)
470
+ dir = session_dir(session_id, state_dir: state_dir)
471
+ FileUtils.mkdir_p(dir)
472
+ AtomicFile.write(File.join(dir, PARENT_FILE),
473
+ JSON.generate({ "parent_id" => check_id!(to), "from" => from, "at" => Time.now.iso8601(3) }))
474
+ end
475
+
476
+ # The parent PARENT_FILE gives the session, nil without one (or with
477
+ # one that doesn't hold an id).
478
+ # @return [String, nil]
479
+ def self.parent_override(session_id, state_dir: default_state_dir)
480
+ file = File.join(session_dir(session_id, state_dir: state_dir), PARENT_FILE)
481
+ # The lists read every session: most have none, so no exception each.
482
+ return nil unless File.exist?(file)
483
+
484
+ data = JSON.parse(File.read(file))
485
+ id = data.is_a?(Hash) && data["parent_id"]
486
+ valid_id?(id) ? id : nil
487
+ rescue JSON::ParserError, SystemCallError
488
+ nil
489
+ end
490
+
456
491
  # An id that is not a session id: it could name a path outside the
457
492
  # sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
458
493
  class InvalidId < ArgumentError; end