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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -1
- data/docs/cli.md +1 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +26 -6
- data/lib/samagotchi/bridge.rb +3 -1
- 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/child_move.rb +235 -0
- data/lib/samagotchi/child_reports.rb +32 -8
- data/lib/samagotchi/send_command.rb +64 -9
- data/lib/samagotchi/session.rb +48 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_manager.rb +314 -25
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +31 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +50 -0
- data/lib/samagotchi/web/public/app.js +201 -18
- data/lib/samagotchi/web/public/chains.js +247 -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
- data/lib/samagotchi/worker.rb +12 -1
- metadata +4 -1
|
@@ -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?
|
|
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?(
|
|
69
|
+
return nil unless Session.exist?(parent_id, state_dir: state_dir)
|
|
65
70
|
|
|
66
|
-
parent_dir = Session.session_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:
|
|
69
|
-
wake_parent(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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
|