samagotchi 0.46.1 → 0.48.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.
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "session"
8
+ require_relative "session_inbox"
9
+ require_relative "worker_sidecar"
10
+ require_relative "log"
11
+ require_relative "tools/delegate_cursor"
12
+
13
+ module Samagotchi
14
+ # Loaded on first use: session_manager requires terminal_ui, which
15
+ # requires KernelLoop and so the tools (a require cycle otherwise).
16
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
17
+
18
+ # A continue moving the previous link's open delegates to the chain's new
19
+ # link (SessionManager.continue_session), instead of archiving them with
20
+ # it. A moved child gets a parent override (Session::PARENT_FILE), never a
21
+ # rewritten session file, so its live worker saving a stale copy changes
22
+ # nothing; its worker reads the override for its rings and at each turn's
23
+ # start (Bridge::FEATURES "reparent").
24
+ #
25
+ # #apply runs between the new link's save and its worker's spawn, while
26
+ # the link is #mark_starting (a ring from a moved child must not spawn a
27
+ # second worker from the child's process until the link's own holds it).
28
+ # Each step is idempotent,
29
+ # and an intent file (INTENT_FILE) in the new link's folder names the move
30
+ # until it is done: a worker that starts and finds one finishes the move
31
+ # (#adopt), and a failed continue moves the same children back.
32
+ module ChildMove
33
+ # <new link dir>/move.json: {from, ids}, while a move is under way.
34
+ INTENT_FILE = "move.json"
35
+ # <new link dir>/starting: {pid, at}, from the move to the worker's spawn.
36
+ STARTING_FILE = "starting"
37
+ # A starting marker older than this, or whose process is gone, is left
38
+ # over from a continue that died: it holds nothing back.
39
+ STARTING_STALE = 60.0
40
+ # Who the note to a moved child is from (ContextNote's label).
41
+ NOTE_SOURCE = "session chain"
42
+ # What a live child's worker must name in its sidecar to be moved.
43
+ FEATURE = "reparent"
44
+
45
+ # What a continue does with the previous link's children: +move+ the
46
+ # direct children whose subtree has an open node (they go to the new
47
+ # link, their subtrees with them), +refuse+ the open ones it can't move
48
+ # (SessionManager::OpenChild, why: an older chi worker or a chi REPL).
49
+ Plan = Data.define(:move, :refuse)
50
+ # A move under way: from the session, the children moved.
51
+ Intent = Data.define(:from, :ids)
52
+
53
+ module_function
54
+
55
+ # @param open [Array<SessionManager::OpenChild>] the previous link's open
56
+ # nodes, at any depth (SessionManager.open_children)
57
+ # @return [Plan]
58
+ def plan(prev_id, open:, state_dir:)
59
+ open_ids = open.map(&:id)
60
+ move = []
61
+ refuse = []
62
+ SessionManager.children_of(prev_id, state_dir: state_dir).each do |row|
63
+ subtree = [row[:id], *SessionManager.descendant_ids(row[:id], state_dir)]
64
+ next if (subtree & open_ids).empty?
65
+
66
+ if row[:archived]
67
+ # An open delegate below an archived one: the archive's cascade
68
+ # would take it, the move doesn't (only unarchived ones move).
69
+ refuse.concat(open.select { |c| subtree.include?(c.id) }.map do |c|
70
+ c.with(why: "under archived #{row[:id][0, 8]}; unarchive that one or archive this one")
71
+ end)
72
+ next
73
+ end
74
+
75
+ why = unmovable(row[:id], state_dir: state_dir)
76
+ if why
77
+ refuse << SessionManager::OpenChild.new(id: row[:id], short_id: row[:id][0, 8], why: why)
78
+ else
79
+ move << row[:id]
80
+ end
81
+ end
82
+ Plan.new(move: move, refuse: refuse)
83
+ end
84
+
85
+ # Why a child's live owner keeps it from moving: a chi REPL holds its
86
+ # own Session and never reads the override; a worker on an older chi
87
+ # neither. A worker with no sidecar yet is starting, on the newest chi
88
+ # installed (SessionManager.worker_command): it moves.
89
+ # @return [String, nil]
90
+ def unmovable(child_id, state_dir:)
91
+ owner = SessionManager.session_owner(child_id, state_dir: state_dir) or return nil
92
+ return "open in a chi REPL; close it there" if owner.tui?
93
+
94
+ sidecar = WorkerSidecar.live(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
95
+ return nil if sidecar.nil? || sidecar.features.include?(FEATURE)
96
+
97
+ "older chi worker#{" #{sidecar.version}" if sidecar.version}; restart it"
98
+ end
99
+
100
+ # Move +ids+ (direct children of +from+) to +to+: the intent, the
101
+ # cursors +to+ lacks (delegates.json), the overrides, the rings waiting
102
+ # in +from+'s children/, a note to each child, then the intent goes.
103
+ # Safe to run again on the same move.
104
+ # @param undo [Boolean] a failed continue moving them back: its note says so
105
+ def apply(ids, from:, to:, state_dir:, undo: false)
106
+ return if ids.empty?
107
+
108
+ write_intent(to, Intent.new(from: from, ids: ids), state_dir: state_dir)
109
+ copy_cursors(ids, from: from, to: to, state_dir: state_dir)
110
+ ids.each { |id| Session.reparent(id, to: to, from: from, state_dir: state_dir) }
111
+ move_rings(ids, from: from, to: to, state_dir: state_dir)
112
+ ids.each { |id| note_child(id, to: to, undo: undo, state_dir: state_dir) }
113
+ clear_intent(to, state_dir: state_dir)
114
+ Log.info(:worker, undo ? "delegates_moved_back" : "delegates_moved", from: from[0, 8], to: to[0, 8], count: ids.size)
115
+ end
116
+
117
+ # The rings in +from+'s children/ from children whose parent is +to+
118
+ # now (a failed continue's link, before it is deleted: a child that
119
+ # read the override just before the undo rang it).
120
+ def return_rings(from:, to:, state_dir:)
121
+ ids = SessionInbox.find_ring_files(Session.session_dir(from, state_dir: state_dir)).filter_map do |file|
122
+ id = SessionInbox.read_ring(file)&.dig(:child_id)
123
+ id if Session.valid_id?(id) && Session.parent_override(id, state_dir: state_dir) == to
124
+ end
125
+ move_rings(ids.uniq, from: from, to: to, state_dir: state_dir)
126
+ end
127
+
128
+ # A worker starting for +session_id+ finishes a move its continue left
129
+ # half done (the intent file is still there): only those children, never
130
+ # the predecessor's others.
131
+ # @return [Array<String>] the ids moved, [] with no intent
132
+ def adopt(session_id, state_dir:)
133
+ intent = read_intent(session_id, state_dir: state_dir) or return []
134
+ apply(intent.ids, from: intent.from, to: session_id, state_dir: state_dir)
135
+ intent.ids
136
+ rescue StandardError => e
137
+ Log.warn(:worker, "delegates_adopt_failed", sid: session_id[0, 8], error: e.class.name, msg: e.message)
138
+ []
139
+ end
140
+
141
+ # @return [Intent, nil]
142
+ def read_intent(session_id, state_dir:)
143
+ data = JSON.parse(File.read(File.join(Session.session_dir(session_id, state_dir: state_dir), INTENT_FILE)))
144
+ return nil unless data.is_a?(Hash) && Session.valid_id?(data["from"]) && data["ids"].is_a?(Array)
145
+
146
+ Intent.new(from: data["from"], ids: data["ids"].select { |id| Session.valid_id?(id) })
147
+ rescue JSON::ParserError, SystemCallError
148
+ nil
149
+ end
150
+
151
+ def write_intent(session_id, intent, state_dir:)
152
+ dir = Session.session_dir(session_id, state_dir: state_dir)
153
+ FileUtils.mkdir_p(dir)
154
+ AtomicFile.write(File.join(dir, INTENT_FILE), JSON.generate({ "from" => intent.from, "ids" => intent.ids }))
155
+ end
156
+
157
+ def clear_intent(session_id, state_dir:)
158
+ FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), INTENT_FILE))
159
+ end
160
+
161
+ def copy_cursors(ids, from:, to:, state_dir:)
162
+ cursors = Tools::DelegateCursors.read(from, state_dir: state_dir).slice(*ids)
163
+ Tools::DelegateCursors.merge(to, cursors, state_dir: state_dir)
164
+ end
165
+
166
+ # A ring keeps its file name (its time orders the rings).
167
+ def move_rings(ids, from:, to:, state_dir:)
168
+ rings = SessionInbox.find_ring_files(Session.session_dir(from, state_dir: state_dir)).select do |file|
169
+ ids.include?(SessionInbox.read_ring(file)&.dig(:child_id))
170
+ end
171
+ return if rings.empty?
172
+
173
+ dir = File.join(Session.session_dir(to, state_dir: state_dir), SessionInbox::CHILDREN_DIR)
174
+ FileUtils.mkdir_p(dir)
175
+ rings.each do |file|
176
+ File.rename(file, File.join(dir, File.basename(file)))
177
+ rescue Errno::ENOENT
178
+ nil # taken meanwhile
179
+ end
180
+ end
181
+
182
+ # The child's system prompt names the parent it started with for its
183
+ # whole life (a prompt-cache miss each time it changed): a note tells a
184
+ # running one where its notes and reports go now.
185
+ def note_child(child_id, to:, state_dir:, undo: false)
186
+ text = if undo
187
+ "Your parent session is #{to[0, 8]} (#{to}) again: the continue that moved you to its next link " \
188
+ "failed. Send notes there, and your reports reach it."
189
+ else
190
+ "Your parent session is now #{to[0, 8]} (#{to}), the next link of its session chain: send notes " \
191
+ "there, and your reports reach it."
192
+ end
193
+ SessionInbox.write_note(child_id, text: text, source: NOTE_SOURCE, state_dir: state_dir)
194
+ end
195
+
196
+ # Hold off a worker for the continue's new link (#starting?) until its
197
+ # own worker holds it: written before the move, removed by that worker
198
+ # once it has the owner lock (Worker#start).
199
+ def mark_starting(session_id, state_dir:)
200
+ dir = Session.session_dir(session_id, state_dir: state_dir)
201
+ FileUtils.mkdir_p(dir)
202
+ AtomicFile.write(File.join(dir, STARTING_FILE), JSON.generate({ "pid" => Process.pid, "at" => Time.now.iso8601(3) }))
203
+ end
204
+
205
+ def clear_starting(session_id, state_dir:)
206
+ FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE))
207
+ end
208
+
209
+ # Whether a continue is moving delegates to +session_id+ right now: its
210
+ # marker is there, fresh, and the process that wrote it runs.
211
+ def starting?(session_id, state_dir:)
212
+ file = File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE)
213
+ return false unless File.exist?(file)
214
+
215
+ data = JSON.parse(File.read(file))
216
+ return false if Time.now - Time.iso8601(data["at"].to_s) > STARTING_STALE
217
+
218
+ process_alive?(data["pid"].to_i)
219
+ rescue JSON::ParserError, ArgumentError, SystemCallError
220
+ false
221
+ end
222
+
223
+ # Signal 0 only asks whether the process exists.
224
+ def process_alive?(pid)
225
+ return false unless pid.positive?
226
+
227
+ Process.kill(0, pid)
228
+ true
229
+ rescue Errno::ESRCH
230
+ false
231
+ rescue Errno::EPERM
232
+ true
233
+ end
234
+ end
235
+ end
@@ -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)
@@ -4,6 +4,7 @@ require_relative "client_id"
4
4
  require_relative "session"
5
5
  require_relative "session_inbox"
6
6
  require_relative "session_manager"
7
+ require_relative "session_chain"
7
8
  require_relative "context_quote"
8
9
  require_relative "reply_wait"
9
10
  require_relative "parent_report"
@@ -34,6 +35,7 @@ module Samagotchi
34
35
  USAGE = <<~TEXT
35
36
  Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
36
37
  chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
38
+ chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
37
39
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
38
40
  chi send --wait [--timeout S] [--format json] ID
39
41
  Sends a message to each session, as if typed in it: a turn starts,
@@ -52,6 +54,14 @@ module Samagotchi
52
54
  web does, and print its id
53
55
  --dir DIR (--new) its folder, the project it belongs to; default
54
56
  the current one
57
+ --continues ID
58
+ (--new) start the next link of that session's chain:
59
+ in its folder, on its model and LLM context, with a
60
+ note carrying its recap; it is archived. last:ID
61
+ names the chain's latest link. Without -m the new
62
+ session waits idle. Refused for a session continued
63
+ already (naming the next link) or with delegates
64
+ still open
55
65
  --model M (--new) its model; default the configured one
56
66
  --llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
57
67
  (--new) its own LLM context strategy (none, stale,forget),
@@ -83,6 +93,7 @@ module Samagotchi
83
93
  f.value "-m", "--message"
84
94
  f.switch "--new"
85
95
  f.value "--dir"
96
+ f.value "--continues"
86
97
  f.value "--model"
87
98
  f.value "--llm-context", key: :llm_strategy
88
99
  f.value "--llm-context-apply", key: :llm_apply
@@ -152,10 +163,14 @@ module Samagotchi
152
163
  # Before the wait-only case, which would drop the images.
153
164
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
154
165
  return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
155
- return usage_error("no message: pass -m TEXT or pipe it in") unless prompt
166
+
167
+ # The next link of a chain may start idle, waiting for its first message.
168
+ idle_link = prompt.nil? && options[:continues]
169
+ return usage_error("--wait needs a message to wait for: pass -m TEXT") if idle_link && options[:wait]
170
+ return usage_error("no message: pass -m TEXT or pipe it in") unless prompt || idle_link
156
171
 
157
172
  begin
158
- prompt = SessionInbox.checked_text(prompt, noun: "message")
173
+ prompt &&= SessionInbox.checked_text(prompt, noun: "message")
159
174
  rescue SessionInbox::NoteRejected => e
160
175
  @stderr.puts("chi send: #{e.message}")
161
176
  return 1
@@ -205,7 +220,7 @@ module Samagotchi
205
220
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
206
221
  return new_options(options) if options[:new]
207
222
 
208
- %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
223
+ %i[dir model continues].each { |key| return usage_error("--#{key} needs --new") if options[key] }
209
224
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
210
225
  return usage_error("give session ids") if options[:ids].empty?
211
226
 
@@ -220,6 +235,15 @@ module Samagotchi
220
235
  def new_options(options)
221
236
  return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
222
237
 
238
+ if options[:continues]
239
+ taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
240
+ LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] }
241
+ unless taken.compact.empty?
242
+ return usage_error("--continues takes the previous session's folder, model and LLM context; " \
243
+ "leave out #{taken.compact.join(", ")}")
244
+ end
245
+ end
246
+
223
247
  words = LLM_CONTEXT_FIELDS.filter_map { |key, field| [field, options[key]] if options[key] }.to_h
224
248
  begin
225
249
  options[:llm_context] = LLMContextOverride.update(nil, words) unless words.empty?
@@ -253,7 +277,10 @@ module Samagotchi
253
277
  # @return [Integer] the exit status
254
278
  def run_new(prompt, options)
255
279
  images = !@images.empty?
256
- if images && (refusal = vision_refusal(options[:model]))
280
+ model, typed = continued_model(options)
281
+ return 1 if model == false
282
+
283
+ if images && (refusal = vision_refusal(model, typed: typed))
257
284
  error_line("chi send: refused: #{refusal}")
258
285
  return 1
259
286
  end
@@ -264,9 +291,16 @@ module Samagotchi
264
291
  through_worker = images || command_like?(prompt)
265
292
  begin
266
293
  start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
267
- session = SessionManager.spawn_session(**start, working_directory: options[:dir],
268
- model_name: options[:model], llm_context: options[:llm_context],
269
- state_dir: @state_dir)
294
+ session = if options[:continues]
295
+ SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
296
+ else
297
+ SessionManager.spawn_session(**start, working_directory: options[:dir],
298
+ model_name: options[:model], llm_context: options[:llm_context],
299
+ state_dir: @state_dir)
300
+ end
301
+ rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
302
+ error_line("chi send: refused: #{e.message}")
303
+ return 1
270
304
  rescue StandardError => e
271
305
  error_line("chi send: could not start a session: #{e.message}")
272
306
  return 1
@@ -275,7 +309,7 @@ module Samagotchi
275
309
  if through_worker
276
310
  return 1 unless deliver_new(session, prompt)
277
311
  else
278
- @info.puts("#{session.id} started")
312
+ @info.puts("#{session.id} started#{continued_words(session)}")
279
313
  end
280
314
  @session_id = session.id
281
315
  return 0 unless options[:wait]
@@ -285,6 +319,27 @@ module Samagotchi
285
319
  timeout: options[:timeout])
286
320
  end
287
321
 
322
+ # --continues: the model the new link will run (the previous link's),
323
+ # for the images check; [nil, nil] without --continues.
324
+ # @return [Array, false] [model_name, model_typed], or false after the
325
+ # error line (no such session)
326
+ def continued_model(options)
327
+ return [options[:model], nil] unless options[:continues]
328
+
329
+ previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
330
+ [previous.model_name, previous.model_typed]
331
+ rescue ArgumentError => e
332
+ error_line("chi send: #{e.message}")
333
+ false
334
+ end
335
+
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
342
+
288
343
  # One existing session, then its next reply. The cursor and baseline
289
344
  # are taken before the message goes in, so neither an older reply nor a
290
345
  # turn that ends before the first look is mistaken for the answer. A
@@ -371,7 +426,7 @@ module Samagotchi
371
426
  end
372
427
 
373
428
  @sent_command = command_ack?(result[:ack])
374
- @info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
429
+ @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
375
430
  true
376
431
  end
377
432
 
@@ -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
 
@@ -72,6 +78,11 @@ module Samagotchi
72
78
  # Started by the `delegate` tool (a fork has a parent_id too, but isn't
73
79
  # one): see #delegate?.
74
80
  attr_writer :delegate
81
+ # The session this one continues (SessionManager.continue_session): the
82
+ # previous link of a chain, "the next day of the same routine", else
83
+ # nil. Not a parent: archiving one link leaves the next alone. The chain
84
+ # itself is derived (SessionChain), never stored.
85
+ attr_accessor :continues
75
86
  # A `chi scratch` session: deleted when its REPL ends, and by the next
76
87
  # sweep (or `chi sessions clean`) when the process died first.
77
88
  attr_accessor :scratch
@@ -125,12 +136,17 @@ module Samagotchi
125
136
  # not saved.
126
137
  attr_accessor :model_warning
127
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
+
128
143
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
129
144
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
130
145
  first_preview: "", test_run: false, pending_question: nil,
131
146
  used_memory_names: [], project_root: nil,
132
147
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
133
- last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [])
148
+ last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
149
+ continues: nil)
134
150
  @id = id
135
151
  @metadata_version = metadata_version
136
152
  @mode = mode
@@ -154,6 +170,7 @@ module Samagotchi
154
170
  @scratch = !!scratch
155
171
  @last_turn = last_turn
156
172
  @delegate = !!delegate
173
+ @continues = continues&.to_s
157
174
  self.llm_context = llm_context
158
175
  @archived = false
159
176
  end
@@ -205,7 +222,7 @@ module Samagotchi
205
222
  # seed); [] by default
206
223
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
207
224
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
208
- scratch: false, model_typed: nil, delegate: false, llm_context: nil)
225
+ scratch: false, model_typed: nil, delegate: false, llm_context: nil, continues: nil)
209
226
  now = Time.now.iso8601(3)
210
227
  resolved_test = if test_run.nil?
211
228
  test_session_env?
@@ -230,7 +247,8 @@ module Samagotchi
230
247
  parent_id: parent_id,
231
248
  scratch: scratch,
232
249
  delegate: delegate,
233
- llm_context: llm_context
250
+ llm_context: llm_context,
251
+ continues: continues
234
252
  )
235
253
  end
236
254
 
@@ -250,6 +268,7 @@ module Samagotchi
250
268
 
251
269
  session = from_h(JSON.parse(File.read(path)))
252
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
253
272
  session
254
273
  rescue JSON::ParserError => e
255
274
  raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
@@ -287,7 +306,8 @@ module Samagotchi
287
306
  "scratch" => false,
288
307
  "last_turn" => nil,
289
308
  "delegate" => false,
290
- "llm_context" => nil
309
+ "llm_context" => nil,
310
+ "continues" => nil
291
311
  }.freeze
292
312
 
293
313
  # A session from a parsed session file. +messages+ false leaves the
@@ -377,6 +397,7 @@ module Samagotchi
377
397
 
378
398
  session = from_h(data, messages: false)
379
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
380
401
  session
381
402
  rescue JSON::ParserError, KeyError, SystemCallError
382
403
  nil
@@ -444,6 +465,29 @@ module Samagotchi
444
465
  File.exist?(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
445
466
  end
446
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
+
447
491
  # An id that is not a session id: it could name a path outside the
448
492
  # sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
449
493
  class InvalidId < ArgumentError; end