samagotchi 0.75.0 → 0.77.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 +72 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +20 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +15 -4
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +34 -0
- data/docs/plugins.md +43 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +105 -16
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +13 -14
- data/lib/samagotchi/memory_bundle/installer.rb +122 -5
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +4 -4
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +5 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/terminal_ui.rb +5 -3
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -2
- data/lib/samagotchi/web/public/app.js +21 -58
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +84 -11
- data/lib/samagotchi/web/public/index.html +32 -36
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +38 -3
- data/lib/samagotchi/web/public/timing.js +11 -2
- data/lib/samagotchi/web/public/toasts.js +69 -0
- metadata +8 -2
- data/lib/samagotchi/web/message_parts.rb +0 -274
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "client_id"
|
|
9
9
|
require_relative "log"
|
|
10
|
+
require_relative "pending_question"
|
|
10
11
|
require_relative "tools/ask_user_question"
|
|
11
12
|
require_relative "guardrails/parent_approvals"
|
|
12
13
|
require_relative "guardrails/parent_continue"
|
|
@@ -87,6 +88,8 @@ module Samagotchi
|
|
|
87
88
|
# A Monitor: re-entered by the specs from the same thread.
|
|
88
89
|
@lock = Monitor.new
|
|
89
90
|
@cv = @lock.new_cond
|
|
91
|
+
# The open question (PendingQuestion), nil with none; replaced, never
|
|
92
|
+
# changed in place (#annotate, #cancel make a new one with #with).
|
|
90
93
|
@pending = nil
|
|
91
94
|
@answer = nil
|
|
92
95
|
# Why the open question was cancelled (#cancel), for the sync path.
|
|
@@ -99,9 +102,10 @@ module Samagotchi
|
|
|
99
102
|
@shelved = nil
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
# @return [
|
|
105
|
+
# @return [PendingQuestion, nil] the question pending now (a value: it
|
|
106
|
+
# never changes once read)
|
|
103
107
|
def pending
|
|
104
|
-
@lock.synchronize { @pending
|
|
108
|
+
@lock.synchronize { @pending }
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
# Ask the model's question (ask_user_question): the kernel's
|
|
@@ -127,8 +131,9 @@ module Samagotchi
|
|
|
127
131
|
# Open a question for the UIs and wait for its answer. Emits
|
|
128
132
|
# :question_requested, persists it to the session, and BLOCKS until
|
|
129
133
|
# answer / cancel wakes it (or the turn is cancelled).
|
|
130
|
-
# The fields
|
|
131
|
-
#
|
|
134
|
+
# The fields become the PendingQuestion as given (no cleaning): any of
|
|
135
|
+
# its members (kind:, approval:, …); a key it doesn't have raises
|
|
136
|
+
# ArgumentError.
|
|
132
137
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
133
138
|
# @param watch [#call, nil] asked every WATCH_INTERVAL seconds while the
|
|
134
139
|
# question waits (outside the lock): a String closes the question with
|
|
@@ -155,7 +160,7 @@ module Samagotchi
|
|
|
155
160
|
# @param on_superseded_close [#call, nil] called when a question that
|
|
156
161
|
# superseded it closes (its poster posts it again if it still
|
|
157
162
|
# stands); nil: posted again as it was
|
|
158
|
-
# @return [
|
|
163
|
+
# @return [PendingQuestion, nil] the pending question; nil when it waits for another
|
|
159
164
|
def post(fields, on_answer:, on_superseded_close: nil)
|
|
160
165
|
standing = { fields: fields, on_answer: on_answer, on_superseded_close: on_superseded_close }
|
|
161
166
|
shelved = @lock.synchronize do
|
|
@@ -179,7 +184,7 @@ module Samagotchi
|
|
|
179
184
|
withdrawn = @lock.synchronize do
|
|
180
185
|
@shelved = nil if id.nil?
|
|
181
186
|
standing = @standing
|
|
182
|
-
next nil unless standing && @pending && @pending
|
|
187
|
+
next nil unless standing && @pending && @pending.id == standing[:id]
|
|
183
188
|
next nil if id && standing[:id].to_s != id.to_s
|
|
184
189
|
|
|
185
190
|
@standing = nil
|
|
@@ -195,7 +200,7 @@ module Samagotchi
|
|
|
195
200
|
|
|
196
201
|
private def ask(fields, watch)
|
|
197
202
|
pending = publish(fields)
|
|
198
|
-
id = pending
|
|
203
|
+
id = pending.id
|
|
199
204
|
|
|
200
205
|
# If a synchronous UI handler is registered (TUI), invoke it inline on the
|
|
201
206
|
# SAME thread that called request_question (TerminalUI's REPL thread is the
|
|
@@ -204,7 +209,7 @@ module Samagotchi
|
|
|
204
209
|
# any future UI that registers a sync handler gets the same guarantee.
|
|
205
210
|
if @sync_handler
|
|
206
211
|
begin
|
|
207
|
-
sync_res = @sync_handler.call(pending.
|
|
212
|
+
sync_res = @sync_handler.call(pending.to_h)
|
|
208
213
|
# Handler may have called answer_question or returned a hash/string.
|
|
209
214
|
# Decided under the lock; saved and announced after it (never emit
|
|
210
215
|
# holding the question lock: a snapshot holds the event lock and
|
|
@@ -298,12 +303,12 @@ module Samagotchi
|
|
|
298
303
|
answer = @lock.synchronize do
|
|
299
304
|
pending = @pending
|
|
300
305
|
raise NotPending, "no pending question" unless pending
|
|
301
|
-
raise NotPending, "id mismatch" unless pending
|
|
306
|
+
raise NotPending, "id mismatch" unless pending.id.to_s == id.to_s
|
|
302
307
|
# First responder wins: after the first answer the turn thread clears
|
|
303
308
|
# @pending in a later lock block, so a second UI's answer can
|
|
304
309
|
# land in between and must not overwrite the first.
|
|
305
310
|
raise NotPending, "question already answered" if @answer
|
|
306
|
-
raise NotPending, "question #{pending
|
|
311
|
+
raise NotPending, "question #{pending.status}" unless pending.status.to_s == "pending"
|
|
307
312
|
|
|
308
313
|
answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
|
|
309
314
|
if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
|
|
@@ -338,15 +343,15 @@ module Samagotchi
|
|
|
338
343
|
def annotate(id, relayed_to:, reason: nil)
|
|
339
344
|
marked = @lock.synchronize do
|
|
340
345
|
pending = @pending
|
|
341
|
-
next nil unless pending && pending
|
|
342
|
-
next nil if @answer || pending
|
|
346
|
+
next nil unless pending && pending.id.to_s == id.to_s
|
|
347
|
+
next nil if @answer || pending.status.to_s != "pending"
|
|
343
348
|
|
|
344
|
-
|
|
349
|
+
@pending = pending = pending.with(relayed_to: relayed_to)
|
|
345
350
|
if session
|
|
346
|
-
session.pending_question = pending
|
|
351
|
+
session.pending_question = pending
|
|
347
352
|
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
348
353
|
end
|
|
349
|
-
pending
|
|
354
|
+
pending.id
|
|
350
355
|
end
|
|
351
356
|
return false unless marked
|
|
352
357
|
|
|
@@ -372,16 +377,16 @@ module Samagotchi
|
|
|
372
377
|
cancelled_id = @lock.synchronize do
|
|
373
378
|
pending = @pending
|
|
374
379
|
next unless pending
|
|
375
|
-
next if id && pending
|
|
376
|
-
next if @answer || pending
|
|
380
|
+
next if id && pending.id.to_s != id.to_s
|
|
381
|
+
next if @answer || pending.status.to_s != "pending"
|
|
377
382
|
# Its poster closes it; nothing waits on a cancel.
|
|
378
|
-
raise NotDismissable, "answer #{Array(pending
|
|
383
|
+
raise NotDismissable, "answer #{Array(pending.options).join(" or ")}" if @standing && id
|
|
379
384
|
next if @standing
|
|
380
385
|
|
|
381
|
-
pending
|
|
386
|
+
@pending = pending.with(status: "cancelled")
|
|
382
387
|
@cancel_reason = reason.to_s
|
|
383
388
|
@cv.broadcast
|
|
384
|
-
pending
|
|
389
|
+
pending.id
|
|
385
390
|
end
|
|
386
391
|
return id.nil? && self.pending.nil? unless cancelled_id
|
|
387
392
|
|
|
@@ -420,21 +425,23 @@ module Samagotchi
|
|
|
420
425
|
# :question_requested to every UI. Observers that stash the event (e.g.
|
|
421
426
|
# TerminalUI handle_question_event) discard it as stale if a sync
|
|
422
427
|
# handler answers and clears it first (drain_pending_question?).
|
|
423
|
-
#
|
|
428
|
+
# The event carries it as a Hash (PendingQuestion#to_h), as every UI
|
|
429
|
+
# and the Bridge's stream read it.
|
|
430
|
+
# @return [PendingQuestion] the pending question
|
|
424
431
|
# @param standing [Hash, nil] a standing question's record (#post)
|
|
425
432
|
def publish(fields, standing: nil)
|
|
426
|
-
pending =
|
|
433
|
+
pending = PendingQuestion.new(id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3))
|
|
427
434
|
@lock.synchronize do
|
|
428
435
|
@pending = pending
|
|
429
436
|
@answer = nil
|
|
430
437
|
@cancel_reason = nil
|
|
431
|
-
@standing = standing&.merge(id: pending
|
|
438
|
+
@standing = standing&.merge(id: pending.id)
|
|
432
439
|
end
|
|
433
440
|
if session
|
|
434
|
-
session.pending_question = pending
|
|
441
|
+
session.pending_question = pending
|
|
435
442
|
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
436
443
|
end
|
|
437
|
-
event = { type: :question_requested, pending_question: pending }
|
|
444
|
+
event = { type: :question_requested, pending_question: pending.to_h }
|
|
438
445
|
event[:standing] = true if standing
|
|
439
446
|
emit(event)
|
|
440
447
|
pending
|
|
@@ -489,11 +496,11 @@ module Samagotchi
|
|
|
489
496
|
# @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
|
|
490
497
|
# @raise [ArgumentError, Refused]
|
|
491
498
|
def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
|
|
492
|
-
opts = Array(pending
|
|
499
|
+
opts = Array(pending.options)
|
|
493
500
|
invalid = sel.reject { |v| opts.include?(v) }
|
|
494
501
|
raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
|
|
495
|
-
raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending
|
|
496
|
-
raise ArgumentError, "selection required" if pending
|
|
502
|
+
raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending.multi_select && sel.size > 1
|
|
503
|
+
raise ArgumentError, "selection required" if pending.multi_select == false && sel.empty? && fm.nil?
|
|
497
504
|
|
|
498
505
|
indices = sel.map { |v| opts.index(v) }
|
|
499
506
|
# The one whose answer settles the approval (Approval.settle, by index).
|
|
@@ -501,7 +508,7 @@ module Samagotchi
|
|
|
501
508
|
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
502
509
|
raise Refused, reason if reason
|
|
503
510
|
end
|
|
504
|
-
answer = { id: pending
|
|
511
|
+
answer = { id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
|
|
505
512
|
answer[:by] = "parent_agent" if parent_agent
|
|
506
513
|
answer
|
|
507
514
|
end
|
|
@@ -513,7 +520,7 @@ module Samagotchi
|
|
|
513
520
|
loop do
|
|
514
521
|
done = @lock.synchronize do
|
|
515
522
|
next true if @answer || cancel_controller&.cancelled?
|
|
516
|
-
next true if @pending.nil? || @pending
|
|
523
|
+
next true if @pending.nil? || @pending.status != "pending"
|
|
517
524
|
|
|
518
525
|
# Wait with timeout to check cancel; 0.2s matches reminder poll
|
|
519
526
|
@cv.wait([0.2, watch ? next_watch - monotonic : 0.2].min.clamp(0.0, 0.2))
|
|
@@ -527,9 +534,9 @@ module Samagotchi
|
|
|
527
534
|
next unless reason
|
|
528
535
|
|
|
529
536
|
closed = @lock.synchronize do
|
|
530
|
-
next false if @answer || @pending.nil? || @pending
|
|
537
|
+
next false if @answer || @pending.nil? || @pending.status != "pending"
|
|
531
538
|
|
|
532
|
-
@pending
|
|
539
|
+
@pending = @pending.with(status: "cancelled")
|
|
533
540
|
true
|
|
534
541
|
end
|
|
535
542
|
return reason if closed
|
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -18,29 +18,28 @@ module Samagotchi
|
|
|
18
18
|
module_function
|
|
19
19
|
|
|
20
20
|
# @param child [Session] the child as loaded (its id, its first prompt)
|
|
21
|
-
# @param pending [
|
|
22
|
-
# when it came from a saved session)
|
|
21
|
+
# @param pending [PendingQuestion] the child's pending question (string
|
|
22
|
+
# keys inside its approval and relay when it came from a saved session)
|
|
23
23
|
# @param relay_id [String] RelayDesk's id
|
|
24
24
|
# @param more [Integer] other delegates' approvals waiting behind this one
|
|
25
25
|
# @return [Hash] Engine#open_question fields
|
|
26
26
|
def card(child, pending, relay_id:, more: 0)
|
|
27
|
-
|
|
28
|
-
facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
|
|
27
|
+
facts = pending.approval.is_a?(Hash) ? deep_symbolize(pending.approval) : {}
|
|
29
28
|
short = child.id.to_s[0, 8]
|
|
30
|
-
inner = pending
|
|
29
|
+
inner = pending.relay.is_a?(Hash) ? deep_symbolize(pending.relay) : nil
|
|
31
30
|
chain = inner ? [*Array(inner[:chain]), short] : [short]
|
|
32
|
-
asked = inner ? inner[:asked].to_s : pending
|
|
31
|
+
asked = inner ? inner[:asked].to_s : pending.question.to_s
|
|
33
32
|
task = inner ? inner[:task].to_s : task_of(child)
|
|
34
33
|
who = chain.reverse.join(" → ")
|
|
35
34
|
{
|
|
36
35
|
question: question_text(who, task, asked),
|
|
37
|
-
options: relabel(Array(pending
|
|
36
|
+
options: relabel(Array(pending.options), facts[:scopes], who),
|
|
38
37
|
header: header(who, more),
|
|
39
38
|
multi_select: false,
|
|
40
|
-
allow_freeform: pending.
|
|
39
|
+
allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
|
|
41
40
|
kind: Guardrails::Approval::KIND,
|
|
42
41
|
approval: facts,
|
|
43
|
-
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending
|
|
42
|
+
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
|
|
44
43
|
task: task, chain: chain, asked: asked, more: more.to_i }
|
|
45
44
|
}
|
|
46
45
|
end
|
|
@@ -70,9 +70,9 @@ module Samagotchi
|
|
|
70
70
|
# of range; [] for a freeform-only deny.
|
|
71
71
|
def labels(indices, question_id)
|
|
72
72
|
pending = @engine.pending_question
|
|
73
|
-
return [] unless pending && pending
|
|
73
|
+
return [] unless pending && pending.id.to_s == question_id
|
|
74
74
|
|
|
75
|
-
options = Array(pending
|
|
75
|
+
options = Array(pending.options)
|
|
76
76
|
indices = Array(indices)
|
|
77
77
|
return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
|
|
78
78
|
|
|
@@ -37,8 +37,8 @@ module Samagotchi
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def relayed?(pending, question_id, relay_id)
|
|
40
|
-
relay = pending
|
|
41
|
-
return false unless relay.is_a?(Hash) && pending
|
|
40
|
+
relay = pending&.relayed_to
|
|
41
|
+
return false unless relay.is_a?(Hash) && pending.id.to_s == question_id.to_s
|
|
42
42
|
|
|
43
43
|
(relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
|
|
44
44
|
end
|
|
@@ -30,7 +30,7 @@ module Samagotchi
|
|
|
30
30
|
# @!attribute text [String, nil] the reply (:done) or the worker's error
|
|
31
31
|
# (:error)
|
|
32
32
|
# @!attribute file [String, nil] the reply's filename, the next cursor
|
|
33
|
-
# @!attribute question [
|
|
33
|
+
# @!attribute question [PendingQuestion, nil] the pending question
|
|
34
34
|
# (:waiting_for_answer)
|
|
35
35
|
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
36
36
|
# reply (:no_reply): "failed", "canceled", "completed" (empty),
|
|
@@ -152,7 +152,7 @@ module Samagotchi
|
|
|
152
152
|
# lists' rule): one a dead worker left waits for no one, and a chi
|
|
153
153
|
# REPL's is answered there.
|
|
154
154
|
pending = session.pending_question
|
|
155
|
-
if pending && !(baseline && pending
|
|
155
|
+
if pending && !(baseline && pending.id == baseline[:question_id]) &&
|
|
156
156
|
(turn.nil? || own_question?(session, turn, claimed)) &&
|
|
157
157
|
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
158
158
|
return Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
@@ -217,7 +217,7 @@ module Samagotchi
|
|
|
217
217
|
# its messages, the question pending then, and when its last turn
|
|
218
218
|
# ended, so a turn that ends before the first look still ends the wait.
|
|
219
219
|
# @return [Hash]
|
|
220
|
-
def baseline_of(session, question_id: session.pending_question&.
|
|
220
|
+
def baseline_of(session, question_id: session.pending_question&.id)
|
|
221
221
|
{ messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
|
|
222
222
|
end
|
|
223
223
|
|
|
@@ -252,7 +252,7 @@ module Samagotchi
|
|
|
252
252
|
end
|
|
253
253
|
|
|
254
254
|
pending = session.pending_question
|
|
255
|
-
return nil unless pending && pending
|
|
255
|
+
return nil unless pending && pending.kind.to_s == "continue"
|
|
256
256
|
return nil unless turn_ids?(session_dir)
|
|
257
257
|
|
|
258
258
|
session.last_turn&.enqueued_id
|
data/lib/samagotchi/row_refs.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# whose +to_h+ the row carries as +refs+ on tool_call_started and
|
|
11
11
|
# tool_call_completed (ToolRowFields::EVENT_KEYS), saved with the result
|
|
12
12
|
# as tool_refs (ToolResponse::SAVED_KEYS) for a reload, which has no
|
|
13
|
-
# plugins (
|
|
13
|
+
# plugins (MessageParts). Display only: the model never sees them.
|
|
14
14
|
#
|
|
15
15
|
# A file ref is any file under the session's folder (plan D7): a relative
|
|
16
16
|
# path is resolved against it, a path outside it is dropped. The folder
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "atomic_file"
|
|
|
8
8
|
require_relative "context_note"
|
|
9
9
|
require_relative "interrupted_turn"
|
|
10
10
|
require_relative "last_turn"
|
|
11
|
+
require_relative "pending_question"
|
|
11
12
|
require_relative "llm_context_override"
|
|
12
13
|
require_relative "session_setup"
|
|
13
14
|
require_relative "prompt_note"
|
|
@@ -49,7 +50,7 @@ module Samagotchi
|
|
|
49
50
|
|
|
50
51
|
attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
|
|
51
52
|
:created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
|
|
52
|
-
:
|
|
53
|
+
:used_memory_names
|
|
53
54
|
# Memories the session was started with (--memory) and memories hidden
|
|
54
55
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
55
56
|
# Names as given; the engine normalizes them.
|
|
@@ -98,6 +99,15 @@ module Samagotchi
|
|
|
98
99
|
# A `chi scratch` session: deleted when its REPL ends, and by the next
|
|
99
100
|
# sweep (or `chi sessions clean`) when the process died first.
|
|
100
101
|
attr_accessor :scratch
|
|
102
|
+
# The question this session waits on (PendingQuestion), saved as
|
|
103
|
+
# session.json's "pending_question"; nil with none. A Hash (a saved
|
|
104
|
+
# record, string or symbol keys) is read with PendingQuestion.from_file.
|
|
105
|
+
attr_reader :pending_question
|
|
106
|
+
|
|
107
|
+
def pending_question=(value)
|
|
108
|
+
@pending_question = PendingQuestion.from_file(value)
|
|
109
|
+
end
|
|
110
|
+
|
|
101
111
|
# How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
|
|
102
112
|
# and the web's notifications (the hub sends it in the summary); nil
|
|
103
113
|
# before the first turn.
|
|
@@ -246,7 +256,7 @@ module Samagotchi
|
|
|
246
256
|
@last_prompt = last_prompt
|
|
247
257
|
@first_preview = first_preview
|
|
248
258
|
@test_run = !!test_run
|
|
249
|
-
|
|
259
|
+
self.pending_question = pending_question
|
|
250
260
|
@used_memory_names = self.class.name_list(used_memory_names)
|
|
251
261
|
@preloaded_memory_names = self.class.name_list(preloaded_memory_names)
|
|
252
262
|
@muted_memory_names = self.class.name_list(muted_memory_names)
|
|
@@ -289,12 +299,12 @@ module Samagotchi
|
|
|
289
299
|
# @return [Hash, nil]
|
|
290
300
|
def waiting_question(live:)
|
|
291
301
|
pending = pending_question
|
|
292
|
-
return nil unless live && pending
|
|
302
|
+
return nil unless live && pending&.id
|
|
293
303
|
|
|
294
|
-
kind = pending
|
|
295
|
-
relayed = pending
|
|
304
|
+
kind = pending.kind.to_s
|
|
305
|
+
relayed = pending.relayed_to
|
|
296
306
|
relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
|
|
297
|
-
{ id: pending
|
|
307
|
+
{ id: pending.id, kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
298
308
|
end
|
|
299
309
|
|
|
300
310
|
# A question saved by a process that no longer runs this session (the
|
|
@@ -306,7 +316,7 @@ module Samagotchi
|
|
|
306
316
|
return nil unless pending
|
|
307
317
|
|
|
308
318
|
@pending_question = nil
|
|
309
|
-
pending
|
|
319
|
+
pending.id.to_s
|
|
310
320
|
end
|
|
311
321
|
|
|
312
322
|
# The stored project root, else (a file from before the field) the
|
|
@@ -390,10 +400,10 @@ module Samagotchi
|
|
|
390
400
|
|
|
391
401
|
# The fields of <id>.json, in the order #save writes them: key => the
|
|
392
402
|
# value for a file without it (REQUIRED for the ones it must have).
|
|
393
|
-
# .from_h and #to_h both go by this table; messages
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
403
|
+
# .from_h and #to_h both go by this table; messages change key type on
|
|
404
|
+
# the way (symbols in memory, strings on disk); last_turn, prompt_notes
|
|
405
|
+
# and pending_question are value objects in memory (LastTurn,
|
|
406
|
+
# PromptNote, PendingQuestion).
|
|
397
407
|
FIELDS = {
|
|
398
408
|
"metadata_version" => 1,
|
|
399
409
|
"id" => REQUIRED,
|
|
@@ -435,8 +445,6 @@ module Samagotchi
|
|
|
435
445
|
[key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
|
|
436
446
|
end
|
|
437
447
|
attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
|
|
438
|
-
pending = attrs[:pending_question]
|
|
439
|
-
attrs[:pending_question] = pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil
|
|
440
448
|
new(**attrs)
|
|
441
449
|
end
|
|
442
450
|
|
|
@@ -557,7 +565,7 @@ module Samagotchi
|
|
|
557
565
|
when "messages"
|
|
558
566
|
(checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
|
|
559
567
|
when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
|
|
560
|
-
when "pending_question" then @pending_question && scrub_utf8(
|
|
568
|
+
when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
|
|
561
569
|
when "test_run" then !!@test_run
|
|
562
570
|
when "delegate" then @delegate
|
|
563
571
|
when "llm_context" then llm_context_file
|
|
@@ -592,6 +592,11 @@ module Samagotchi
|
|
|
592
592
|
@engine.switch_model!(name, persist_default: persist_default)
|
|
593
593
|
@default_model = @engine.default_model_name if persist_default
|
|
594
594
|
persist_session_model
|
|
595
|
+
# The ctx % on the new model's window, before its first turn; saved
|
|
596
|
+
# too, so the session's card (the hub's summary reads analytics.json)
|
|
597
|
+
# stops showing the old model's % before a turn ends.
|
|
598
|
+
@engine.announce_switched_context!
|
|
599
|
+
@engine.metrics.persist(state_dir: @engine.session_state_dir)
|
|
595
600
|
end
|
|
596
601
|
|
|
597
602
|
def persist_session_model
|
|
@@ -244,8 +244,7 @@ module Samagotchi
|
|
|
244
244
|
end
|
|
245
245
|
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
246
246
|
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
247
|
-
child_env
|
|
248
|
-
child_env["XDG_CONFIG_HOME"] = Paths.config_home unless ENV.fetch("XDG_CONFIG_HOME", "").to_s.strip.empty?
|
|
247
|
+
child_env.merge!(Paths.absolute_xdg)
|
|
249
248
|
# The spawner's environment must not reach the worker's guardrails (a
|
|
250
249
|
# parent agent starting or waking a session): Process.spawn unsets a
|
|
251
250
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "totals"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class SessionMetrics
|
|
7
|
+
# A run of consecutive turns on one model (their records' model_ref, the
|
|
8
|
+
# asked host:model): /stats and the web's ctx/cost tooltip split the
|
|
9
|
+
# session's tokens, cost and speed by these, in order, so A -> B -> A is
|
|
10
|
+
# three. Derived from the turn records as they are counted, never saved
|
|
11
|
+
# on their own. A record with no ref (a turn stopped before it asked a
|
|
12
|
+
# model) joins the segment before it; records with none before the first
|
|
13
|
+
# one that names a model (a session older than model_ref) make one
|
|
14
|
+
# leading segment with no ref.
|
|
15
|
+
#
|
|
16
|
+
# +prefill_tokens+ / +prefill_cost_estimate+: what the segment's first
|
|
17
|
+
# turn prefilled on its model after the switch (the records'
|
|
18
|
+
# switch_prefill_*), 0 / nil for none.
|
|
19
|
+
class ModelSegment < Data.define(:model_ref, :started_at, :ended_at, :turns, :totals, :prefill_tokens,
|
|
20
|
+
:prefill_cost_estimate)
|
|
21
|
+
# The most rows a snapshot sends: older ones fold into one.
|
|
22
|
+
CAP = 6
|
|
23
|
+
|
|
24
|
+
# A segment opened by +record+ (its first turn).
|
|
25
|
+
# @param record [Hash] a turn record (symbol keys)
|
|
26
|
+
# @return [ModelSegment]
|
|
27
|
+
def self.open(record)
|
|
28
|
+
ref = record[:model_ref].to_s
|
|
29
|
+
new(model_ref: ref.empty? ? nil : ref, started_at: record[:started_at], ended_at: nil, turns: 0,
|
|
30
|
+
totals: Totals.zero, prefill_tokens: 0, prefill_cost_estimate: nil).add(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The segment a snapshot shows for the running turn when it is on
|
|
34
|
+
# another model than the last segment's: no finished turn yet.
|
|
35
|
+
# @param ref [String]
|
|
36
|
+
# @param started_at [String, nil]
|
|
37
|
+
# @return [ModelSegment]
|
|
38
|
+
def self.empty(ref, started_at)
|
|
39
|
+
new(model_ref: ref, started_at: started_at, ended_at: nil, turns: 0, totals: Totals.zero, prefill_tokens: 0,
|
|
40
|
+
prefill_cost_estimate: nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether +record+ starts a new segment after this one: it names a
|
|
44
|
+
# model, and another one than this segment's.
|
|
45
|
+
def self.splits?(segment, record)
|
|
46
|
+
ref = record[:model_ref].to_s
|
|
47
|
+
segment.nil? || (!ref.empty? && segment.model_ref != ref)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# This segment with one more finished turn record.
|
|
51
|
+
# @return [ModelSegment]
|
|
52
|
+
def add(record)
|
|
53
|
+
tokens = record[:switch_prefill_tokens]
|
|
54
|
+
estimate = record[:switch_prefill_cost_estimate]
|
|
55
|
+
with(ended_at: record[:completed_at] || ended_at, turns: turns + 1, totals: totals.add(record),
|
|
56
|
+
prefill_tokens: prefill_tokens + (tokens.is_a?(Numeric) ? tokens : 0),
|
|
57
|
+
prefill_cost_estimate: estimate.is_a?(Numeric) ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# This segment with the running turn (SessionMetrics::TurnState) in:
|
|
61
|
+
# its finished generations and the prefill it recorded so far; it
|
|
62
|
+
# runs on, so it has no end.
|
|
63
|
+
# @return [ModelSegment]
|
|
64
|
+
def with_running(turn)
|
|
65
|
+
estimate = turn.switch_prefill_cost_estimate
|
|
66
|
+
with(ended_at: nil, turns: turns + 1, totals: totals.with_running(turn),
|
|
67
|
+
prefill_tokens: prefill_tokens + turn.switch_prefill_tokens.to_i,
|
|
68
|
+
prefill_cost_estimate: estimate ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The row a snapshot carries (JSON-ready). +ended_at+ nil: the segment
|
|
72
|
+
# runs now.
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def to_h
|
|
75
|
+
tokens = totals.tokens_block
|
|
76
|
+
{
|
|
77
|
+
model_ref: model_ref, started_at: started_at, ended_at: ended_at, turns: turns,
|
|
78
|
+
**tokens.slice(:prompt_sum, :completion_sum, :cached_sum, :cost_sum, :cost_estimate_sum, :avg_decode_tps,
|
|
79
|
+
:last_decode_tps, :tps_source),
|
|
80
|
+
prefill_tokens: prefill_tokens, prefill_cost_estimate: prefill_cost_estimate
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The rows of +segments+ a snapshot sends: none for fewer than 2 (one
|
|
85
|
+
# model: the plain totals say it all); over CAP, the oldest fold into
|
|
86
|
+
# one row with no ref that says how many it holds (folded_segments).
|
|
87
|
+
# @param segments [Array<ModelSegment>]
|
|
88
|
+
# @return [Array<Hash>]
|
|
89
|
+
def self.rows(segments)
|
|
90
|
+
return [] if segments.size < 2
|
|
91
|
+
return segments.map(&:to_h) if segments.size <= CAP
|
|
92
|
+
|
|
93
|
+
older = segments.first(segments.size - CAP + 1)
|
|
94
|
+
folded = older.drop(1).reduce(older.first.with(model_ref: nil)) do |sum, segment|
|
|
95
|
+
sum.merge(segment)
|
|
96
|
+
end
|
|
97
|
+
[folded.to_h.merge(folded_segments: older.size), *segments.last(CAP - 1).map(&:to_h)]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# This segment and a later one as one row (the folded older ones).
|
|
101
|
+
# @return [ModelSegment]
|
|
102
|
+
def merge(later)
|
|
103
|
+
estimate = [prefill_cost_estimate, later.prefill_cost_estimate].compact
|
|
104
|
+
with(ended_at: later.ended_at, turns: turns + later.turns, totals: totals.merge(later.totals),
|
|
105
|
+
prefill_tokens: prefill_tokens + later.prefill_tokens, prefill_cost_estimate: estimate.empty? ? nil : estimate.sum)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|