samagotchi 0.46.1 → 0.47.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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "session"
5
+ require_relative "recap_store"
6
+
7
+ module Samagotchi
8
+ # Session chains: a session that continues an earlier one (Session#continues),
9
+ # "the next day of the same routine". The chain is derived, never stored:
10
+ # follow +continues+ back for the earlier links, forward (the session whose
11
+ # +continues+ names this one) for the later ones. A link is continued at
12
+ # most once (SessionManager.continue_session refuses a second), so the
13
+ # chain's latest link is the one nobody continues.
14
+ module SessionChain
15
+ # `--continues last:<id>`: the latest link of the chain <id> is in.
16
+ LAST_PREFIX = "last:"
17
+ # Who the carry note is from (ContextNote's label: "note from session chain").
18
+ NOTE_SOURCE = "session chain"
19
+ # The most of the previous link's recap the carry note takes; a recap is
20
+ # a few sentences, this only keeps a runaway one under the note's 16 KiB.
21
+ RECAP_CHARS = 8000
22
+
23
+ module_function
24
+
25
+ # The session that continues +id+, else nil. Archived ones count: an
26
+ # archived link is still a link.
27
+ # @return [String, nil]
28
+ def next_of(id, state_dir:)
29
+ Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
30
+ .find { |s| s.continues == id }&.id
31
+ end
32
+
33
+ # The chain's latest link from +id+ on: +id+ itself when nobody
34
+ # continues it. One listing, walked forward (a loop a hand-edited file
35
+ # could make ends where it started).
36
+ # @return [String]
37
+ def latest(id, state_dir:)
38
+ nexts = {}
39
+ Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true).each do |s|
40
+ nexts[s.continues] ||= s.id if s.continues
41
+ end
42
+ seen = [id]
43
+ while (following = nexts[seen.last]) && !seen.include?(following)
44
+ seen << following
45
+ end
46
+ seen.last
47
+ end
48
+
49
+ # A session to continue as the CLI and the web name it: an id, a unique
50
+ # prefix, or `last:<id or prefix>` for that chain's latest link.
51
+ # @return [String] the full id of an existing session
52
+ # @raise [ArgumentError] none such (Session::AmbiguousId for a prefix of several)
53
+ def resolve(ref, state_dir:)
54
+ given = ref.to_s.strip
55
+ last = given.start_with?(LAST_PREFIX)
56
+ given = given.delete_prefix(LAST_PREFIX) if last
57
+ raise ArgumentError, "no session #{ref}" unless Session.valid_id?(given)
58
+
59
+ id = Session.resolve_id(given, state_dir: state_dir)
60
+ raise ArgumentError, "no session #{ref}" unless Session.exist?(id, state_dir: state_dir)
61
+
62
+ last ? latest(id, state_dir: state_dir) : id
63
+ end
64
+
65
+ # The context note a new link starts with: which session it continues,
66
+ # from when, and that session's recap with its time, when it has one.
67
+ # Never more than the recap: no transcript.
68
+ # @param previous [Session]
69
+ # @param recap [Hash, nil] RecapStore.read's
70
+ # @return [String]
71
+ def note_text(previous, recap:)
72
+ head = "This session continues #{previous.id[0, 8]} (#{day(previous.created_at)}), the previous link of its chain."
73
+ text = recap && recap[:text].to_s.strip
74
+ return "#{head} It left no recap." if text.nil? || text.empty?
75
+
76
+ text = "#{text[0, RECAP_CHARS - 1]}…" if text.length > RECAP_CHARS
77
+ as_of = clock(recap[:created_at])
78
+ "#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
79
+ end
80
+
81
+ def day(iso)
82
+ Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
83
+ rescue ArgumentError
84
+ iso.to_s
85
+ end
86
+
87
+ def clock(iso)
88
+ iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
89
+ rescue ArgumentError
90
+ nil
91
+ end
92
+ end
93
+ end
@@ -30,6 +30,7 @@ require_relative "continue_offer"
30
30
  require_relative "image_store"
31
31
  require_relative "plugin_session_state"
32
32
  require_relative "context_sources"
33
+ require_relative "session_chain"
33
34
  require_relative "terminal_ui"
34
35
 
35
36
  module Samagotchi
@@ -115,6 +116,33 @@ module Samagotchi
115
116
  end
116
117
  end
117
118
 
119
+ # A continue that can't happen (#continue_session). reason: :continued
120
+ # (another session continues it already: +ids+ names it), :open_children
121
+ # (children still running, waiting, live or with a reply it wasn't given:
122
+ # +ids+ names them) or :folder_gone (its folder isn't there).
123
+ class ContinueRefused < StandardError
124
+ attr_reader :session_id, :reason, :ids
125
+
126
+ def initialize(session_id, reason, ids: [], detail: nil)
127
+ @session_id = session_id
128
+ @reason = reason
129
+ @ids = ids
130
+ super(continue_refused_message(detail))
131
+ end
132
+
133
+ private
134
+
135
+ def continue_refused_message(detail)
136
+ short = @session_id[0, 8]
137
+ case @reason
138
+ when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
139
+ when :open_children
140
+ "#{short} has delegates still open: #{detail}; wait for them, stop them or archive them, then continue"
141
+ else "#{short}'s folder #{detail} is gone"
142
+ end
143
+ end
144
+ end
145
+
118
146
  # Spawn a new background session that processes the given prompt (or,
119
147
  # with none, waits idle for input).
120
148
  #
@@ -133,11 +161,17 @@ module Samagotchi
133
161
  # (the prompt's preview by default, else the seed's first user message)
134
162
  # @param llm_context [LLMContextOverride, nil] the session's own
135
163
  # llm_context values (chi --llm-context; a fork copies its parent's)
164
+ # @param continues [String, nil] the session this one continues (a
165
+ # session field; #continue_session)
166
+ # @param note [String, nil] a context note the session starts with,
167
+ # from +note_source+: queued before its worker spawns, so its first
168
+ # turn sees it
136
169
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
137
170
  # and #model_warning: an id its host's saved list doesn't have
138
171
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
139
172
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
140
- title: nil, delegate: false, llm_context: nil)
173
+ title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
174
+ note_source: "chi")
141
175
  sd = state_dir || Session.default_state_dir
142
176
  # The resolved ref is stored (a resumed session keeps its model when an
143
177
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -158,7 +192,8 @@ module Samagotchi
158
192
  parent_id: parent_id,
159
193
  messages: messages,
160
194
  delegate: delegate,
161
- llm_context: llm_context
195
+ llm_context: llm_context,
196
+ continues: continues
162
197
  )
163
198
  session.model_warning = model_warning
164
199
  # With no prompt there is no first turn to run (an attaching UI sends
@@ -174,10 +209,175 @@ module Samagotchi
174
209
  session.seed_images_dropped = dropped
175
210
  end
176
211
  setup_session_directory(session_dir, session, state_dir: sd)
212
+ SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
177
213
  spawn_worker_for_session(session, state_dir: sd)
178
214
  session
179
215
  end
180
216
 
217
+ # How long #continue_session waits for the previous link's worker to
218
+ # write its recap (IdleRecap's own request timeout).
219
+ CONTINUE_RECAP_WAIT = 30.0
220
+ # In the previous link's folder: one continue of a session at a time.
221
+ CONTINUE_LOCK = "continue.lock"
222
+ # How often a last:<id> continue looks again when another continue
223
+ # moved the chain's end while it waited for the lock.
224
+ CONTINUE_TRIES = 3
225
+
226
+ # Start the next link of a chain: a new session that continues
227
+ # +id_or_ref+ (Session#continues) in its folder, on its model (the name
228
+ # it was typed as, so an alias holds) and with its own llm_context,
229
+ # starting with a context note from chi: the link and the previous
230
+ # link's recap (SessionChain.note_text). The previous link is archived
231
+ # first, with its delegates (archive_session's rules: a turn running,
232
+ # a prompt queued or the step-limit question refuse; an idle worker is
233
+ # stopped), so a refusal starts nothing. Before that its live worker is
234
+ # asked for a recap and given up to +recap_wait+ seconds to write it.
235
+ # One continue of a session at a time (a lock in its folder): a double
236
+ # click finds the first one's link and is refused; a last:<id> one
237
+ # follows the chain to its new end instead.
238
+ # @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
239
+ # @param prompt [String, nil] its first message (nil: it starts idle)
240
+ # @param title [String, nil] what the lists show before the first turn;
241
+ # by default the prompt, else the previous link's preview
242
+ # @return [Session] the new link (#model_warning as spawn_session's)
243
+ # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
244
+ # @raise [ContinueRefused] continued already, delegates still open, or its folder is gone
245
+ # @raise [ArchiveRefused, OwnedByTUI] as archive_session
246
+ def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
247
+ archive_wait: 5)
248
+ sd = state_dir || Session.default_state_dir
249
+ CONTINUE_TRIES.times do |try|
250
+ id = SessionChain.resolve(id_or_ref, state_dir: sd)
251
+ result = with_continue_lock(id, sd) do
252
+ follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
253
+ next :moved if follow && SessionChain.next_of(id, state_dir: sd)
254
+
255
+ continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
256
+ archive_wait: archive_wait)
257
+ end
258
+ return result unless result == :moved
259
+ end
260
+ end
261
+
262
+ private_class_method def self.with_continue_lock(id, state_dir, &)
263
+ dir = Session.session_dir(id, state_dir: state_dir)
264
+ FileUtils.mkdir_p(dir)
265
+ File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
266
+ lock.flock(File::LOCK_EX)
267
+ yield
268
+ end
269
+ end
270
+
271
+ private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
272
+ previous = Session.load(id, state_dir: state_dir)
273
+ check_continue!(previous, state_dir)
274
+ # archive_session's refusals that can be known now, before the wait.
275
+ archivable!(id, state_dir)
276
+ recap_before_archive(id, state_dir, wait: recap_wait)
277
+ # Again: a turn sent to it during the wait may have delegated.
278
+ check_open_children!(id, state_dir)
279
+ already = [id, *descendant_ids(id, state_dir)].select do |sid|
280
+ ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
281
+ end
282
+ archived = archive_session(id, state_dir: state_dir, wait: archive_wait)[:archived] - already
283
+ begin
284
+ spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir)
285
+ rescue StandardError
286
+ # Nothing started: what this archived goes back to the lists, and
287
+ # a new link saved before its worker failed to spawn goes (left,
288
+ # it would hold the chain: "continued already", last: to it).
289
+ discard_failed_link(id, state_dir)
290
+ archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
291
+ raise
292
+ end
293
+ end
294
+
295
+ # The link a failed #continue_session saved (no other continues +id+:
296
+ # check_continue! made sure under the lock).
297
+ private_class_method def self.discard_failed_link(id, state_dir)
298
+ orphan = SessionChain.next_of(id, state_dir: state_dir) or return
299
+ delete_session(orphan, state_dir: state_dir)
300
+ rescue StandardError => e
301
+ Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
302
+ end
303
+
304
+ private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:)
305
+ recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
306
+ start = prompt.to_s.strip.empty? ? nil : prompt
307
+ title = previous.first_preview if title.to_s.strip.empty? && start.nil?
308
+ spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
309
+ model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
310
+ continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
311
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir)
312
+ end
313
+
314
+ # The refusals that come before anything changes: continued already (no
315
+ # forks in a chain), a delegate still open (its report would go to the
316
+ # archived link), the folder gone (the worker would run elsewhere), a
317
+ # model whose host config.yml no longer has.
318
+ # @raise [ContinueRefused, ModelProfile::MissingModel]
319
+ private_class_method def self.check_continue!(previous, state_dir)
320
+ if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
321
+ raise ContinueRefused.new(previous.id, :continued, ids: [following])
322
+ end
323
+
324
+ check_open_children!(previous.id, state_dir)
325
+ # Its model's host, as spawn_session checks it: before the archive
326
+ # stops the previous link's worker.
327
+ ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
328
+ dir = previous.working_directory.to_s
329
+ raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
330
+ end
331
+
332
+ # A child the next link couldn't follow once its parent is archived.
333
+ OpenChild = Data.define(:id, :short_id, :why)
334
+
335
+ # @raise [ContinueRefused] while #open_children has any
336
+ private_class_method def self.check_open_children!(id, state_dir)
337
+ open = open_children(id, state_dir)
338
+ return if open.empty?
339
+
340
+ raise ContinueRefused.new(id, :open_children, ids: open.map(&:id),
341
+ detail: open.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
342
+ end
343
+
344
+ # The unarchived children, at any depth (the archive's cascade), of the
345
+ # session still running, waiting for an answer, with a live worker, or
346
+ # (a delegate) with a reply its parent wasn't given (ChildrenStatus).
347
+ # @return [Array<OpenChild>]
348
+ private_class_method def self.open_children(id, state_dir)
349
+ require_relative "children_status"
350
+ [id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
351
+ .filter_map do |child|
352
+ why = if %w[waiting running].include?(child.state) then child.state
353
+ elsif child.live then "live"
354
+ elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
355
+ end
356
+ why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
357
+ end
358
+ end
359
+
360
+ # Ask the session's live worker for a recap now (Bridge POST /recap:
361
+ # IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
362
+ # to change. A worker that is gone wrote its recap when it left; one
363
+ # that has nothing new to say, too short a conversation, or recaps off
364
+ # answers at once. Best effort: the continue goes on without.
365
+ private_class_method def self.recap_before_archive(id, state_dir, wait:)
366
+ return unless worker_live?(id, state_dir: state_dir)
367
+
368
+ dir = Session.session_dir(id, state_dir: state_dir)
369
+ client = BridgeClient.discover(id, session_dir: dir) or return
370
+ before = RecapStore.read(dir)
371
+ reply = client.request_recap
372
+ asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
373
+ return unless %w[started in_flight].include?(asked)
374
+
375
+ BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
376
+ rescue StandardError => e
377
+ Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
378
+ nil
379
+ end
380
+
181
381
  # Build the opts hash passed to Process.spawn for a forked worker. The
182
382
  # child inherits this process's ENV and reads config.yml itself (as it
183
383
  # is when the worker starts); opts[:env] adds to it (merged, not
@@ -279,9 +479,11 @@ module Samagotchi
279
479
  # @param include_archived [Boolean] archived sessions too
280
480
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
281
481
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
282
- # parent_short_id:, delegate:, archived:, scratch:, test_run:, waiting:, waiting_id:, stopped_by:}; busy =
482
+ # parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
483
+ # waiting_id:, stopped_by:}; busy =
283
484
  # live with a turn running, delegate = started by the delegate tool
284
- # (Session#delegate?; a fork has a parent_id but isn't one), recap = the saved recap's first sentence, project =
485
+ # (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
486
+ # previous link of its chain, Session#continues), recap = the saved recap's first sentence, project =
285
487
  # Session#project_root, waiting = the kind of question it waits on
286
488
  # ("question", "approval", "hook"; Session#waiting_question) or nil,
287
489
  # waiting_id = that question's id or nil, relayed_to = the parent
@@ -310,7 +512,8 @@ module Samagotchi
310
512
  owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
311
513
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
312
514
  budget_tokens: -> { budgets.call(s) })&.round(1),
313
- parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), delegate: s.delegate?, archived: s.archived,
515
+ parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
516
+ continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
314
517
  scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
315
518
  end
316
519
  (limit ? summaries.first(limit) : summaries.to_a)
@@ -357,21 +560,29 @@ module Samagotchi
357
560
  def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
358
561
  sd = state_dir || Session.default_state_dir
359
562
  id = archive_target(id_or_prefix, sd)
360
- raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: sd).scratch
361
-
362
- tree = [id, *descendant_ids(id, sd)]
363
- owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
364
- tree.each do |sid|
365
- refusal = archive_refusal(sid, owners[sid], sd)
366
- raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
367
- end
368
-
563
+ tree, owners = archivable!(id, sd)
369
564
  stopped = tree.select { |sid| owners[sid] }
370
565
  stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
371
566
  archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
372
567
  { id: id, archived: archived, stopped: stopped, discarded: discarded }
373
568
  end
374
569
 
570
+ # The session and its descendants, with their owners, when they can be
571
+ # archived now (#archive_refusal for each), else the refusal.
572
+ # @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
573
+ # @raise [ArchiveRefused, OwnedByTUI]
574
+ private_class_method def self.archivable!(id, state_dir)
575
+ raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
576
+
577
+ tree = [id, *descendant_ids(id, state_dir)]
578
+ owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
579
+ tree.each do |sid|
580
+ refusal = archive_refusal(sid, owners[sid], state_dir)
581
+ raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
582
+ end
583
+ [tree, owners]
584
+ end
585
+
375
586
  # What keeps +sid+ (in an archive's tree) from being archived: :busy (a
376
587
  # turn runs), :queued (input/ holds a prompt, which outlives the stop and
377
588
  # runs on a resume, live worker or not), :offer (a live worker's continue
@@ -170,6 +170,7 @@ module Samagotchi
170
170
  # its git project's root, or nil
171
171
  # parent_id: the session that delegated it (the delegate tool), or nil
172
172
  # delegate: started by the delegate tool (a fork has a parent_id too)
173
+ # continues: the session it continues (its chain's previous link), or nil
173
174
  # archived: hidden from the lists (only with --archived can it be true)
174
175
  # scratch: a `chi scratch` session (deleted when its REPL ends, a
175
176
  # leftover one at the next sweep)
@@ -178,7 +179,7 @@ module Samagotchi
178
179
  # hook: chi answer or the web answers it), or nil; waiting_id: its
179
180
  # id, for chi answer --question; relayed_to: the parent session (short
180
181
  # id) whose card it also waits in (the approval relay), or nil
181
- keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id delegate archived scratch ctx_pct
182
+ keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
182
183
  waiting waiting_id relayed_to stopped_by]
183
184
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
184
185
  when "tsv"
@@ -199,6 +200,8 @@ module Samagotchi
199
200
  end
200
201
  # A delegated session points at its parent.
201
202
  child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
203
+ # A chain's link points at the one it continues.
204
+ child += " ↪ #{summary[:continues_short_id]}" if summary[:continues_short_id]
202
205
  flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
203
206
  stopped_by: summary[:stopped_by])
204
207
  ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
@@ -244,6 +247,7 @@ module Samagotchi
244
247
  row = lambda do |s|
245
248
  flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
246
249
  child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
250
+ child += " ↪ #{s.continues[0, 8]}" if s.continues
247
251
  # A question waits for an answer (chi answer, the web, chi --attach).
248
252
  live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
249
253
  waiting = s.waiting_question(live: !!live)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.46.1"
4
+ VERSION = "0.47.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -594,6 +594,7 @@ module Samagotchi
594
594
  if prompt.to_s.strip.empty? && !idle
595
595
  return error_response(400, "missing_fields", "prompt is required")
596
596
  end
597
+ return handle_continue(body, prompt, idle) if body.key?("continues")
597
598
 
598
599
  # dir: the folder the chat starts in (the page's scope); without it,
599
600
  # the server's own cwd.
@@ -632,6 +633,53 @@ module Samagotchi
632
633
  json_response(201, session_to_json(session).merge(bridge_port: port))
633
634
  end
634
635
 
636
+ # POST /api/sessions with continues: <id, prefix or last:id>: the next
637
+ # link of that session's chain (SessionManager.continue_session), in its
638
+ # folder, on its model and with its llm_context, so dir, model and
639
+ # llm_context are refused beside it. 409 continued (with next_id, the
640
+ # link that continues it already: the page opens that one),
641
+ # open_children (ids), folder_gone, busy, scratch or owned_by_tui.
642
+ def handle_continue(body, prompt, idle)
643
+ given = body["continues"]
644
+ unless given.is_a?(String) && !given.strip.empty?
645
+ return error_response(400, "invalid_continues", "continues is a session id")
646
+ end
647
+ if (fields = %w[dir model model_name llm_context].select { |key| body.key?(key) }).any?
648
+ return error_response(400, "invalid_continues",
649
+ "continues takes the previous session's folder, model and llm_context; " \
650
+ "leave out #{fields.join(", ")}")
651
+ end
652
+
653
+ preview = idle ? body["preview"].to_s.strip : ""
654
+ session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, title: preview.empty? ? nil : preview)
655
+ return error if error
656
+
657
+ port = await_bridge_port(session.id)
658
+ [session.continues, session.id].each { |sid| @hub&.touch(sid) }
659
+ record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
660
+ json_response(201, session_to_json(session).merge(bridge_port: port))
661
+ end
662
+
663
+ # SessionManager.continue_session, its refusals as responses: [session,
664
+ # nil] or [nil, the response]. Only its own errors: what fails after
665
+ # the new link started is not a refusal of it.
666
+ def continue_from(given, prompt:, title:)
667
+ [@manager.continue_session(given, prompt: prompt, title: title, state_dir: @state_dir), nil]
668
+ rescue SessionManager::ContinueRefused => e
669
+ extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
670
+ [nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
671
+ rescue SessionManager::ArchiveRefused => e
672
+ [nil, error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)]
673
+ rescue SessionManager::OwnedByTUI
674
+ [nil, error_response(409, "owned_by_tui", "session #{given} is open in a chi REPL; close it there first")]
675
+ rescue ModelProfile::MissingModel => e
676
+ [nil, error_response(400, "invalid_model", e.message)]
677
+ rescue Session::AmbiguousId => e
678
+ [nil, error_response(400, "ambiguous_id", e.message)]
679
+ rescue ArgumentError => e
680
+ [nil, error_response(404, "not_found", e.message)]
681
+ end
682
+
635
683
  # The create request's "llm_context" ({strategy:, apply:, budget:},
636
684
  # each a word as /llm-context takes it, "default" for unset) as an
637
685
  # override: [override, nil] (nil when absent or all default), or