samagotchi 0.52.0 → 0.54.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 +58 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +1 -1
- data/docs/testing.md +5 -3
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +35 -44
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +0 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +132 -135
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_registry.rb +13 -0
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +184 -190
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +4 -28
- data/lib/samagotchi/session_commands.rb +32 -4
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +45 -257
- data/lib/samagotchi/session_metrics.rb +18 -21
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +25 -3
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -8
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -8
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/thinking_chip.js +31 -21
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +9 -1
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "rbconfig"
|
|
8
8
|
require_relative "client_id"
|
|
9
|
+
require_relative "analytics_file"
|
|
9
10
|
require_relative "atomic_file"
|
|
10
11
|
require_relative "config"
|
|
11
12
|
require_relative "delivery"
|
|
@@ -64,6 +65,8 @@ module Samagotchi
|
|
|
64
65
|
autoload :Worker, File.expand_path("worker", __dir__)
|
|
65
66
|
# session_retention requires this file.
|
|
66
67
|
autoload :SessionRetention, File.expand_path("session_retention", __dir__)
|
|
68
|
+
# session_continue requires this file.
|
|
69
|
+
autoload :SessionContinue, File.expand_path("session_continue", __dir__)
|
|
67
70
|
|
|
68
71
|
class SessionManager
|
|
69
72
|
# Raised when the interactive TUI owns the session: it runs its own Engine
|
|
@@ -119,35 +122,6 @@ module Samagotchi
|
|
|
119
122
|
end
|
|
120
123
|
end
|
|
121
124
|
|
|
122
|
-
# A continue that can't happen (#continue_session). reason: :continued
|
|
123
|
-
# (another session continues it already: +ids+ names it), :open_children
|
|
124
|
-
# (open children it can't move to the new link: on an older chi's
|
|
125
|
-
# worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
|
|
126
|
-
# folder isn't there).
|
|
127
|
-
class ContinueRefused < StandardError
|
|
128
|
-
attr_reader :session_id, :reason, :ids
|
|
129
|
-
|
|
130
|
-
def initialize(session_id, reason, ids: [], detail: nil)
|
|
131
|
-
@session_id = session_id
|
|
132
|
-
@reason = reason
|
|
133
|
-
@ids = ids
|
|
134
|
-
super(continue_refused_message(detail))
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
private
|
|
138
|
-
|
|
139
|
-
def continue_refused_message(detail)
|
|
140
|
-
short = @session_id[0, 8]
|
|
141
|
-
case @reason
|
|
142
|
-
when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
|
|
143
|
-
when :open_children
|
|
144
|
-
"#{short} has delegates still open that can't move to the next link: #{detail}; " \
|
|
145
|
-
"then continue (or wait for them, stop them or archive them)"
|
|
146
|
-
else "#{short}'s folder #{detail} is gone"
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
125
|
# Spawn a new background session that processes the given prompt (or,
|
|
152
126
|
# with none, waits idle for input).
|
|
153
127
|
#
|
|
@@ -167,7 +141,7 @@ module Samagotchi
|
|
|
167
141
|
# @param setup [SessionSetup] the session's own settings (chi
|
|
168
142
|
# --llm-context; a fork and a continue copy their session's)
|
|
169
143
|
# @param continues [String, nil] the session this one continues (a
|
|
170
|
-
# session field;
|
|
144
|
+
# session field; SessionContinue)
|
|
171
145
|
# @param note [String, nil] a context note the session starts with,
|
|
172
146
|
# from +note_source+: queued before its worker spawns, so its first
|
|
173
147
|
# turn sees it
|
|
@@ -222,227 +196,9 @@ module Samagotchi
|
|
|
222
196
|
session
|
|
223
197
|
end
|
|
224
198
|
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
# In the previous link's folder: one continue of a session at a time.
|
|
229
|
-
CONTINUE_LOCK = "continue.lock"
|
|
230
|
-
# How often a last:<id> continue looks again when another continue
|
|
231
|
-
# moved the chain's end while it waited for the lock.
|
|
232
|
-
CONTINUE_TRIES = 3
|
|
233
|
-
|
|
234
|
-
# Start the next link of a chain: a new session that continues
|
|
235
|
-
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
236
|
-
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
237
|
-
# starting with a context note from chi: the link and the previous
|
|
238
|
-
# link's recap (SessionChain.note_text). The previous link is archived
|
|
239
|
-
# first, with its finished delegates (archive_session's rules: a turn
|
|
240
|
-
# running, a prompt queued or the step-limit question refuse; an idle
|
|
241
|
-
# worker is stopped), so a refusal starts nothing. Its open delegates
|
|
242
|
-
# (running, waiting, live, an unreported reply, at any depth below them)
|
|
243
|
-
# move to the new link instead (ChildMove): their reports reach it.
|
|
244
|
-
# Before that its live worker is asked for a recap and given up to
|
|
245
|
-
# +recap_wait+ seconds to write it.
|
|
246
|
-
# One continue of a session at a time (a lock in its folder): a double
|
|
247
|
-
# click finds the first one's link and is refused; a last:<id> one
|
|
248
|
-
# follows the chain to its new end instead.
|
|
249
|
-
# @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
|
|
250
|
-
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
251
|
-
# @param title [String, nil] what the lists show before the first turn;
|
|
252
|
-
# by default the prompt, else the previous link's preview
|
|
253
|
-
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
254
|
-
# #moved_children: the delegates it took over)
|
|
255
|
-
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
256
|
-
# @raise [ContinueRefused] continued already, delegates open that can't
|
|
257
|
-
# move, or its folder is gone
|
|
258
|
-
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
259
|
-
def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
|
|
260
|
-
archive_wait: 5)
|
|
261
|
-
sd = state_dir || Session.default_state_dir
|
|
262
|
-
CONTINUE_TRIES.times do |try|
|
|
263
|
-
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
264
|
-
result = with_continue_lock(id, sd) do
|
|
265
|
-
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
|
|
266
|
-
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
267
|
-
|
|
268
|
-
continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
269
|
-
archive_wait: archive_wait)
|
|
270
|
-
end
|
|
271
|
-
return result unless result == :moved
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
private_class_method def self.with_continue_lock(id, state_dir, &)
|
|
276
|
-
dir = Session.session_dir(id, state_dir: state_dir)
|
|
277
|
-
FileUtils.mkdir_p(dir)
|
|
278
|
-
File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
279
|
-
lock.flock(File::LOCK_EX)
|
|
280
|
-
yield
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
285
|
-
previous = Session.load(id, state_dir: state_dir)
|
|
286
|
-
plan = check_continue!(previous, state_dir)
|
|
287
|
-
# archive_session's refusals that can be known now, before the wait.
|
|
288
|
-
archivable!(id, state_dir, except: plan.move)
|
|
289
|
-
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
290
|
-
# Again: a turn sent to it during the wait may have delegated (they
|
|
291
|
-
# move too), or a delegate finished.
|
|
292
|
-
plan = move_plan!(id, state_dir)
|
|
293
|
-
already = [id, *descendant_ids(id, state_dir, except: plan.move)].select do |sid|
|
|
294
|
-
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
295
|
-
end
|
|
296
|
-
archived = archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)[:archived] - already
|
|
297
|
-
begin
|
|
298
|
-
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
|
|
299
|
-
rescue StandardError
|
|
300
|
-
# Nothing started: the delegates go back, what this archived goes
|
|
301
|
-
# back to the lists, and a new link saved before its worker failed
|
|
302
|
-
# to spawn goes (left, it would hold the chain: "continued already",
|
|
303
|
-
# last: to it).
|
|
304
|
-
undo_move(id, state_dir, moved: plan.move)
|
|
305
|
-
begin
|
|
306
|
-
discard_failed_link(id, state_dir)
|
|
307
|
-
ensure
|
|
308
|
-
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
309
|
-
wake_for_rings(id, state_dir) unless plan.move.empty?
|
|
310
|
-
end
|
|
311
|
-
raise
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# The delegates a failed #continue_session moved, or began to (its
|
|
316
|
-
# intent names them), go back to +id+.
|
|
317
|
-
private_class_method def self.undo_move(id, state_dir, moved:)
|
|
318
|
-
link = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
319
|
-
ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
|
|
320
|
-
ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
|
|
321
|
-
ChildMove.clear_intent(link, state_dir: state_dir)
|
|
322
|
-
rescue StandardError => e
|
|
323
|
-
Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
# The link a failed #continue_session saved (no other continues +id+:
|
|
327
|
-
# check_continue! made sure under the lock): its worker stopped if one
|
|
328
|
-
# started, then the rings a moved delegate wrote into it just before
|
|
329
|
-
# the undo go back to +id+, then the link goes. A worker that outlives
|
|
330
|
-
# the stop is raised (DeleteRefused): the link stays, and the caller
|
|
331
|
-
# hears it.
|
|
332
|
-
private_class_method def self.discard_failed_link(id, state_dir)
|
|
333
|
-
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
334
|
-
if refuse_tui!(orphan, state_dir: state_dir) && !stop_session(orphan, state_dir: state_dir, wait: 10)
|
|
335
|
-
raise DeleteRefused.new(orphan, :still_stopping)
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
|
|
339
|
-
delete_session(orphan, state_dir: state_dir)
|
|
340
|
-
rescue DeleteRefused
|
|
341
|
-
raise
|
|
342
|
-
rescue StandardError => e
|
|
343
|
-
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
# A failed continue's previous link, back in the lists, with rings that
|
|
347
|
-
# came in meanwhile: wake it for them, as the ring's own wake would have.
|
|
348
|
-
private_class_method def self.wake_for_rings(id, state_dir)
|
|
349
|
-
return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
|
|
350
|
-
|
|
351
|
-
wake_for_report(id, state_dir: state_dir)
|
|
352
|
-
rescue StandardError => e
|
|
353
|
-
Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
357
|
-
# while +move+ goes to it.
|
|
358
|
-
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
|
|
359
|
-
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
360
|
-
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
361
|
-
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
362
|
-
before_spawn = lambda do |link|
|
|
363
|
-
ChildMove.mark_starting(link.id, state_dir: state_dir)
|
|
364
|
-
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
365
|
-
end
|
|
366
|
-
link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
|
|
367
|
-
model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
|
|
368
|
-
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
369
|
-
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
|
|
370
|
-
link.moved_children = move
|
|
371
|
-
link
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
# The refusals that come before anything changes: continued already (no
|
|
375
|
-
# forks in a chain), an open delegate that can't move to the new link
|
|
376
|
-
# (ChildMove.plan), the folder gone (the worker would run elsewhere), a
|
|
377
|
-
# model whose host config.yml no longer has.
|
|
378
|
-
# @return [ChildMove::Plan]
|
|
379
|
-
# @raise [ContinueRefused, ModelProfile::MissingModel]
|
|
380
|
-
private_class_method def self.check_continue!(previous, state_dir)
|
|
381
|
-
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
382
|
-
raise ContinueRefused.new(previous.id, :continued, ids: [following])
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
plan = move_plan!(previous.id, state_dir)
|
|
386
|
-
# Its model's host, as spawn_session checks it: before the archive
|
|
387
|
-
# stops the previous link's worker.
|
|
388
|
-
ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
|
|
389
|
-
dir = previous.working_directory.to_s
|
|
390
|
-
raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
391
|
-
|
|
392
|
-
plan
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
# A child still open below a session (#open_children); why: running,
|
|
396
|
-
# waiting, live, unreported reply, or why it can't move (ChildMove).
|
|
397
|
-
OpenChild = Data.define(:id, :short_id, :why)
|
|
398
|
-
|
|
399
|
-
# The delegates a continue of +id+ moves (ChildMove.plan).
|
|
400
|
-
# @return [ChildMove::Plan]
|
|
401
|
-
# @raise [ContinueRefused] for open ones it can't move
|
|
402
|
-
private_class_method def self.move_plan!(id, state_dir)
|
|
403
|
-
plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
|
|
404
|
-
return plan if plan.refuse.empty?
|
|
405
|
-
|
|
406
|
-
raise ContinueRefused.new(id, :open_children, ids: plan.refuse.map(&:id),
|
|
407
|
-
detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
# The unarchived children, at any depth (the archive's cascade), of the
|
|
411
|
-
# session still running, waiting for an answer, with a live worker, or
|
|
412
|
-
# (a delegate) with a reply its parent wasn't given (ChildrenStatus).
|
|
413
|
-
# @return [Array<OpenChild>]
|
|
414
|
-
def self.open_children(id, state_dir)
|
|
415
|
-
require_relative "children_status"
|
|
416
|
-
[id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
|
|
417
|
-
.filter_map do |child|
|
|
418
|
-
why = if %w[waiting running].include?(child.state) then child.state
|
|
419
|
-
elsif child.live then "live"
|
|
420
|
-
elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
|
|
421
|
-
end
|
|
422
|
-
why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
|
|
423
|
-
end
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
# Ask the session's live worker for a recap now (Bridge POST /recap:
|
|
427
|
-
# IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
|
|
428
|
-
# to change. A worker that is gone wrote its recap when it left; one
|
|
429
|
-
# that has nothing new to say, too short a conversation, or recaps off
|
|
430
|
-
# answers at once. Best effort: the continue goes on without.
|
|
431
|
-
private_class_method def self.recap_before_archive(id, state_dir, wait:)
|
|
432
|
-
return unless worker_live?(id, state_dir: state_dir)
|
|
433
|
-
|
|
434
|
-
dir = Session.session_dir(id, state_dir: state_dir)
|
|
435
|
-
client = BridgeClient.discover(id, session_dir: dir) or return
|
|
436
|
-
before = RecapStore.read(dir)
|
|
437
|
-
reply = client.request_recap
|
|
438
|
-
asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
|
|
439
|
-
return unless %w[started in_flight].include?(asked)
|
|
440
|
-
|
|
441
|
-
BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
|
|
442
|
-
rescue StandardError => e
|
|
443
|
-
Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
|
|
444
|
-
nil
|
|
445
|
-
end
|
|
199
|
+
# Start the next link of a chain (SessionContinue.run, which has the
|
|
200
|
+
# rules and raises SessionContinue::Refused).
|
|
201
|
+
def self.continue_session(...) = SessionContinue.run(...)
|
|
446
202
|
|
|
447
203
|
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
448
204
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
@@ -636,10 +392,11 @@ module Samagotchi
|
|
|
636
392
|
end
|
|
637
393
|
|
|
638
394
|
# The session and its descendants, with their owners, when they can be
|
|
639
|
-
# archived now (#archive_refusal for each), else the refusal.
|
|
395
|
+
# archived now (#archive_refusal for each), else the refusal. Public for
|
|
396
|
+
# SessionContinue, which checks before its recap wait.
|
|
640
397
|
# @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
|
|
641
398
|
# @raise [ArchiveRefused, OwnedByTUI]
|
|
642
|
-
|
|
399
|
+
def self.archivable!(id, state_dir, except: [])
|
|
643
400
|
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
644
401
|
|
|
645
402
|
tree = [id, *descendant_ids(id, state_dir, except: except)]
|
|
@@ -974,7 +731,7 @@ module Samagotchi
|
|
|
974
731
|
# session keeps. "pid" is the pid file workers wrote until 2026-10-01:
|
|
975
732
|
# nothing writes or reads it now, but a folder from before still has one
|
|
976
733
|
# and is still empty.
|
|
977
|
-
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE,
|
|
734
|
+
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, AnalyticsFile::NAME].freeze
|
|
978
735
|
EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
|
|
979
736
|
EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
|
|
980
737
|
|
|
@@ -1058,17 +815,48 @@ module Samagotchi
|
|
|
1058
815
|
path = File.join(dir, name)
|
|
1059
816
|
if EMPTY_SKELETON_DIRS.include?(name)
|
|
1060
817
|
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
|
|
1061
|
-
elsif name ==
|
|
1062
|
-
|
|
818
|
+
elsif name == AnalyticsFile::NAME
|
|
819
|
+
analytics_empty?(path)
|
|
1063
820
|
# Cards and notices from before any turn (Bridge::CardStore).
|
|
1064
821
|
elsif name == "cards.json"
|
|
1065
|
-
|
|
822
|
+
cards_empty?(path)
|
|
1066
823
|
else
|
|
1067
824
|
EMPTY_SKELETON_FILES.include?(name)
|
|
1068
825
|
end
|
|
1069
826
|
end
|
|
1070
827
|
end
|
|
1071
828
|
|
|
829
|
+
# A folder's analytics.json is empty only when it is a JSON object with
|
|
830
|
+
# an empty turn_records array. Anything unreadable, not JSON, not an
|
|
831
|
+
# object, the wrong type, or turn_records the wrong shape reads as
|
|
832
|
+
# "not empty": a broken analytics keeps the folder instead of crashing
|
|
833
|
+
# the retention sweep (or of a wrong-shape one reading as empty).
|
|
834
|
+
def self.analytics_empty?(path)
|
|
835
|
+
data = read_json(path)
|
|
836
|
+
turns = data.is_a?(Hash) ? data["turn_records"] : nil
|
|
837
|
+
turns.is_a?(Array) && turns.empty?
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# A folder's cards.json is empty only when it is a JSON object whose
|
|
841
|
+
# turns is an integer zero. Anything unreadable, not JSON, not an
|
|
842
|
+
# object, the wrong type, or turns not an integer reads as "not empty":
|
|
843
|
+
# a broken or wrong-shape cards file keeps the folder instead of
|
|
844
|
+
# raising (Array#/Hash#to_i) or reading as empty.
|
|
845
|
+
def self.cards_empty?(path)
|
|
846
|
+
data = read_json(path)
|
|
847
|
+
turns = data.is_a?(Hash) ? data["turns"] : nil
|
|
848
|
+
turns.is_a?(Integer) && turns.zero?
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# A session-dir JSON file's contents, or nil when it is missing,
|
|
852
|
+
# unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
|
|
853
|
+
# broken file reads as none, so its folder is kept, not deleted.
|
|
854
|
+
def self.read_json(path)
|
|
855
|
+
JSON.parse(File.read(path))
|
|
856
|
+
rescue JSON::ParserError, SystemCallError
|
|
857
|
+
nil
|
|
858
|
+
end
|
|
859
|
+
|
|
1072
860
|
# Delete one session: its <id>.json, the whole <id>/ directory
|
|
1073
861
|
# (history sidecars, input, output, notes, images) and its attached
|
|
1074
862
|
# context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
|
|
@@ -5,6 +5,7 @@ require "monitor"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "securerandom"
|
|
8
|
+
require_relative "analytics_file"
|
|
8
9
|
require_relative "atomic_file"
|
|
9
10
|
require_relative "token_usage"
|
|
10
11
|
require_relative "model_price"
|
|
@@ -172,8 +173,8 @@ module Samagotchi
|
|
|
172
173
|
# @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
|
|
173
174
|
# @return [SavedSummary, nil]
|
|
174
175
|
def self.saved_summary(session_dir, budget_tokens: nil)
|
|
175
|
-
data =
|
|
176
|
-
return nil unless data
|
|
176
|
+
data = AnalyticsFile.read(session_dir)
|
|
177
|
+
return nil unless data
|
|
177
178
|
|
|
178
179
|
tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
|
|
179
180
|
count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
|
|
@@ -182,7 +183,7 @@ module Samagotchi
|
|
|
182
183
|
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
|
|
183
184
|
cost_estimate_sum: count.call("cost_estimate_sum"),
|
|
184
185
|
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
|
|
185
|
-
rescue
|
|
186
|
+
rescue TypeError
|
|
186
187
|
nil
|
|
187
188
|
end
|
|
188
189
|
|
|
@@ -217,10 +218,10 @@ module Samagotchi
|
|
|
217
218
|
def self.saved_tool_records(session_dir, turn_id)
|
|
218
219
|
return [] if turn_id.to_s.empty?
|
|
219
220
|
|
|
220
|
-
records =
|
|
221
|
+
records = AnalyticsFile.read(session_dir)&.dig("tool_records")
|
|
221
222
|
Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
|
|
222
223
|
.sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
|
|
223
|
-
rescue
|
|
224
|
+
rescue TypeError
|
|
224
225
|
[]
|
|
225
226
|
end
|
|
226
227
|
|
|
@@ -234,9 +235,9 @@ module Samagotchi
|
|
|
234
235
|
def self.saved_turn_record(session_dir, turn_id)
|
|
235
236
|
return nil if turn_id.to_s.empty?
|
|
236
237
|
|
|
237
|
-
records =
|
|
238
|
+
records = AnalyticsFile.read(session_dir)&.dig("turn_records")
|
|
238
239
|
Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
|
|
239
|
-
rescue
|
|
240
|
+
rescue TypeError
|
|
240
241
|
nil
|
|
241
242
|
end
|
|
242
243
|
|
|
@@ -422,8 +423,7 @@ module Samagotchi
|
|
|
422
423
|
# (XDG) location; passing an explicit nil would override it and break
|
|
423
424
|
# File.join.
|
|
424
425
|
FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
|
|
425
|
-
|
|
426
|
-
AtomicFile.write(path, JSON.pretty_generate(data) + "\n")
|
|
426
|
+
AtomicFile.write(AnalyticsFile.path(dir), JSON.pretty_generate(data) + "\n")
|
|
427
427
|
@mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
|
|
428
428
|
true
|
|
429
429
|
rescue StandardError
|
|
@@ -824,6 +824,8 @@ module Samagotchi
|
|
|
824
824
|
|
|
825
825
|
called_as = event.dig(:activity, :called_as)
|
|
826
826
|
active[:called_as] = called_as.to_s if called_as
|
|
827
|
+
# A grep that found nothing (ok): the reloaded web row says "no match".
|
|
828
|
+
active[:no_match] = true if event.dig(:activity, :no_match)
|
|
827
829
|
finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
|
|
828
830
|
end
|
|
829
831
|
end
|
|
@@ -831,7 +833,7 @@ module Samagotchi
|
|
|
831
833
|
# Caller holds the mutex.
|
|
832
834
|
def finish_tool_record(active, status, waited_ms: 0)
|
|
833
835
|
record = active.slice(
|
|
834
|
-
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
|
|
836
|
+
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :no_match, :started_at
|
|
835
837
|
).merge(
|
|
836
838
|
status: status,
|
|
837
839
|
completed_at: now.iso8601(3),
|
|
@@ -977,11 +979,8 @@ module Samagotchi
|
|
|
977
979
|
# broken file, or an older shape: nothing, or zeros where keys are
|
|
978
980
|
# missing). Caller holds the mutex.
|
|
979
981
|
def load_persisted
|
|
980
|
-
|
|
981
|
-
return unless
|
|
982
|
-
|
|
983
|
-
prior = JSON.parse(File.read(path))
|
|
984
|
-
return unless prior.is_a?(Hash)
|
|
982
|
+
prior = AnalyticsFile.read(session_dir(@session_id, @state_dir))
|
|
983
|
+
return unless prior
|
|
985
984
|
|
|
986
985
|
loaded_turns = loaded_records(prior["turn_records"])
|
|
987
986
|
@turn_records = loaded_turns + @turn_records
|
|
@@ -1004,12 +1003,10 @@ module Samagotchi
|
|
|
1004
1003
|
@memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
|
|
1005
1004
|
# The window last seen, until this process's first generation reports.
|
|
1006
1005
|
window = prior["context"].is_a?(Hash) ? prior["context"] : {}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
rescue JSON::ParserError, SystemCallError
|
|
1012
|
-
nil
|
|
1006
|
+
return unless window["window_tokens"] && @context_window_tokens.nil?
|
|
1007
|
+
|
|
1008
|
+
@context_window_tokens = window["window_tokens"]
|
|
1009
|
+
@context_window_source = window["window_source"]
|
|
1013
1010
|
end
|
|
1014
1011
|
|
|
1015
1012
|
def symbolized(hash)
|
|
@@ -5,6 +5,7 @@ require_relative "session"
|
|
|
5
5
|
require_relative "session_manager"
|
|
6
6
|
require_relative "session_retention"
|
|
7
7
|
require_relative "project_scope"
|
|
8
|
+
require_relative "analytics_file"
|
|
8
9
|
require_relative "session_metrics"
|
|
9
10
|
require_relative "recap_store"
|
|
10
11
|
require_relative "session_delete_command"
|
|
@@ -337,10 +338,10 @@ module Samagotchi
|
|
|
337
338
|
return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
|
|
338
339
|
end
|
|
339
340
|
end
|
|
340
|
-
return [nil, false, nil] unless File.file?(
|
|
341
|
+
return [nil, false, nil] unless File.file?(Samagotchi::AnalyticsFile.path(dir))
|
|
341
342
|
|
|
342
343
|
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
|
|
343
|
-
note = live_worker?(session.id) ? "(no live worker answered; from the saved
|
|
344
|
+
note = live_worker?(session.id) ? "(no live worker answered; from the saved #{Samagotchi::AnalyticsFile::NAME})" : nil
|
|
344
345
|
[snapshot, false, note]
|
|
345
346
|
end
|
|
346
347
|
|
|
@@ -1268,7 +1268,8 @@ module Samagotchi
|
|
|
1268
1268
|
# (it ended first).
|
|
1269
1269
|
def join_tool_line(part, duration_ms: nil)
|
|
1270
1270
|
output = part[:output]
|
|
1271
|
-
|
|
1271
|
+
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
1272
|
+
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool], command: command)
|
|
1272
1273
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1273
1274
|
params: part[:params], status: status,
|
|
1274
1275
|
description: ToolView.description_title(part[:view] || {}) }
|
|
@@ -20,7 +20,7 @@ module Samagotchi
|
|
|
20
20
|
# (no UI: the same text from the CLI).
|
|
21
21
|
# @return [String] "(no metrics yet)" without a snapshot
|
|
22
22
|
def self.format_session_metrics(snapshot)
|
|
23
|
-
(@formatter ||= Object.new.extend(Formatting)).
|
|
23
|
+
(@formatter ||= Object.new.extend(Formatting)).format_session_metrics(snapshot)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def format_tool_activity_line(activity, duration_ms: nil)
|
|
@@ -543,6 +543,8 @@ module Samagotchi
|
|
|
543
543
|
value.length > width ? value[0, width] : value
|
|
544
544
|
end
|
|
545
545
|
|
|
546
|
+
public
|
|
547
|
+
|
|
546
548
|
# Render the analytics snapshot as a compact, user-facing report. Raw event
|
|
547
549
|
# logs (debug-only) are intentionally excluded; this surface is for the REPL
|
|
548
550
|
# and `chi sessions stats` (the module function above).
|
|
@@ -607,6 +609,8 @@ module Samagotchi
|
|
|
607
609
|
lines.join("\n")
|
|
608
610
|
end
|
|
609
611
|
|
|
612
|
+
private
|
|
613
|
+
|
|
610
614
|
# " (25.0% of the 64000 budget)" when the session's llm_context budget
|
|
611
615
|
# is set and smaller than the window, else " (12.5%)": the live meter
|
|
612
616
|
# counts against the smaller of the two (ContextStatus#counted_against),
|
|
@@ -238,6 +238,10 @@ module Samagotchi
|
|
|
238
238
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
239
239
|
# nothing and return (no transient session, no banner).
|
|
240
240
|
return if @non_interactive && @prompt.nil?
|
|
241
|
+
# A UI-only command (/stats, /exit …) isn't run without the REPL, and
|
|
242
|
+
# isn't a prompt either: refused as chi send refuses it, before any
|
|
243
|
+
# session is made (bin/chi exits 1).
|
|
244
|
+
return refuse_one_shot_ui_command if @non_interactive && SessionCommands.ui_only_entry(@prompt)
|
|
241
245
|
|
|
242
246
|
session = @resume_session || @engine.store_model!(Session.new_session(
|
|
243
247
|
mode: "assist",
|
|
@@ -269,6 +273,16 @@ module Samagotchi
|
|
|
269
273
|
return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
|
|
270
274
|
|
|
271
275
|
if @prompt && @non_interactive
|
|
276
|
+
# A /cut TEXT / /queue TEXT prompt runs its text as the turn's
|
|
277
|
+
# prompt, the same as the idle REPL does (Formatting#steer_choice);
|
|
278
|
+
# a bare /cut / /queue (no text) is refused above as a UI command,
|
|
279
|
+
# so only the text here. The images are the text's, not the words'.
|
|
280
|
+
one_shot_prompt =
|
|
281
|
+
if (choice = steer_choice(@prompt)) && !choice.last.empty?
|
|
282
|
+
choice.last
|
|
283
|
+
else
|
|
284
|
+
@prompt
|
|
285
|
+
end
|
|
272
286
|
# Headless / CI mode: run directly without TTY rendering.
|
|
273
287
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
274
288
|
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
@@ -277,11 +291,11 @@ module Samagotchi
|
|
|
277
291
|
begin
|
|
278
292
|
result = @engine.run_turn(
|
|
279
293
|
session,
|
|
280
|
-
|
|
294
|
+
one_shot_prompt,
|
|
281
295
|
on_event: sink,
|
|
282
296
|
max_iterations: IterationLimit.for(no_interrupt: true),
|
|
283
297
|
cancel_controller: nil,
|
|
284
|
-
images: ImageInput.extract(
|
|
298
|
+
images: ImageInput.extract(one_shot_prompt)
|
|
285
299
|
)
|
|
286
300
|
rescue LLM::ProviderError, ImageStore::Error => e
|
|
287
301
|
return one_shot_failed(session, e)
|
|
@@ -348,7 +362,7 @@ module Samagotchi
|
|
|
348
362
|
# so the session is kept and `chi --resume` takes it up again.
|
|
349
363
|
# @return [Symbol] :turn_failed
|
|
350
364
|
def one_shot_failed(session, error)
|
|
351
|
-
warn "Error: #{
|
|
365
|
+
warn "Error: #{@engine.failure_summary(error)}"
|
|
352
366
|
if !@scratch && Session.exist?(session.id)
|
|
353
367
|
warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
|
|
354
368
|
end
|
|
@@ -371,6 +385,14 @@ module Samagotchi
|
|
|
371
385
|
:turn_failed if result.status == :error
|
|
372
386
|
end
|
|
373
387
|
|
|
388
|
+
# `chi -p /stats --non-interactive`: why it doesn't run, on stderr.
|
|
389
|
+
# @return [Symbol] :turn_failed
|
|
390
|
+
def refuse_one_shot_ui_command
|
|
391
|
+
name = SessionCommands.ui_only_entry(@prompt).name
|
|
392
|
+
warn "chi: refused: #{SessionCommands.ui_only_refusal(name, @resume_session&.id, runner: "--non-interactive")}"
|
|
393
|
+
:turn_failed
|
|
394
|
+
end
|
|
395
|
+
|
|
374
396
|
# Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
|
|
375
397
|
# a cancel note in the session (it saves only a failed turn), so save
|
|
376
398
|
# it here: `chi --resume` takes it up again. A scratch session goes in
|