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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/docs/cli.md +1 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +23 -5
- data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/send_command.rb +59 -9
- data/lib/samagotchi/session.rb +13 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_manager.rb +225 -14
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -0
- data/lib/samagotchi/web/public/app.js +200 -18
- data/lib/samagotchi/web/public/chains.js +234 -0
- data/lib/samagotchi/web/public/data.js +12 -0
- data/lib/samagotchi/web/public/families_view.js +3 -2
- data/lib/samagotchi/web/public/index.html +29 -6
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/sessions_list.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +7 -3
- data/lib/samagotchi/web/session_summary.rb +3 -0
- metadata +3 -1
|
@@ -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:,
|
|
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),
|
|
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),
|
|
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
|
-
|
|
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)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|