samagotchi 0.43.0 → 0.45.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 +89 -1
- data/docs/cli.md +29 -7
- data/docs/configuration.md +18 -0
- data/docs/context.md +2 -1
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/context_status.rb +18 -5
- data/lib/samagotchi/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- data/lib/samagotchi/kernel_loop.rb +24 -9
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/llm_context_forget.rb +6 -2
- data/lib/samagotchi/llm_context_notice.rb +96 -0
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_profile.rb +71 -2
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/self_report.rb +31 -2
- data/lib/samagotchi/session_commands.rb +24 -7
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +39 -6
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -13
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +38 -19
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +42 -15
- data/lib/samagotchi/web/public/index.html +7 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +3 -3
- data/lib/samagotchi/web/public/turn_events.js +50 -4
- data/lib/samagotchi/web/public/turn_html.js +33 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -2
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +156 -20
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +2 -1
|
@@ -187,7 +187,6 @@ module Samagotchi
|
|
|
187
187
|
# named before the worker's first turn fails on it. Only a warning: some
|
|
188
188
|
# hosts serve ids they don't list (a one-model llama.cpp server takes any
|
|
189
189
|
# name, OpenRouter's :nitro variants), so the session starts anyway.
|
|
190
|
-
# Never asks a host.
|
|
191
190
|
#
|
|
192
191
|
# Only a ref that names a host is checked (its alias resolved first, as
|
|
193
192
|
# ModelRef.parse does), and only against that host's own list: a bare id
|
|
@@ -195,11 +194,24 @@ module Samagotchi
|
|
|
195
194
|
# single list can judge it. Nothing is checked either without a saved
|
|
196
195
|
# list for the host or with one older than ModelListStore::TTL_SECONDS
|
|
197
196
|
# (a week: the host may serve different models by now).
|
|
197
|
+
#
|
|
198
|
+
# A saved list that lacks the id may itself be stale by content: a
|
|
199
|
+
# one-model server was reloaded with another model while the list still
|
|
200
|
+
# names the old one. So a miss in a saved list older than
|
|
201
|
+
# RELIST_AFTER_SECONDS re-lists that one host once, judges again against
|
|
202
|
+
# what it lists now, and lets the saved list take the re-list's ids (the
|
|
203
|
+
# default re-list saves them too). A younger saved list, a hit, no list,
|
|
204
|
+
# a stale list (TTL_SECONDS) or a ref that names no host never asks a
|
|
205
|
+
# host; a re-list that fails, times out (RE_LIST_TIMEOUT_SECONDS) or
|
|
206
|
+
# lists nothing warns from the saved list (a host that is down is no
|
|
207
|
+
# evidence either way).
|
|
198
208
|
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
199
209
|
# entries); config.yml's by default
|
|
200
210
|
# @param lists [#read] ModelListStore by default
|
|
211
|
+
# @param relist [#call, nil] how a miss re-lists the host:
|
|
212
|
+
# (host_name, env) -> ids or nil. HostRegistry#list_models by default.
|
|
201
213
|
# @return [String, nil]
|
|
202
|
-
def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil)
|
|
214
|
+
def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil, relist: nil)
|
|
203
215
|
require_relative "config"
|
|
204
216
|
require_relative "model_list_store"
|
|
205
217
|
hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
|
|
@@ -210,10 +222,67 @@ module Samagotchi
|
|
|
210
222
|
|
|
211
223
|
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
212
224
|
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
225
|
+
# A saved list this recent is taken at its word. Re-listing every miss
|
|
226
|
+
# would make each launch of an id a host serves but never lists (a
|
|
227
|
+
# gateway's round-robin aliases) pay the re-list's cap and warn
|
|
228
|
+
# anyway; a list that old is the case worth catching (a one-model
|
|
229
|
+
# server reloaded with another model).
|
|
230
|
+
return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
|
|
231
|
+
|
|
232
|
+
fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
|
|
233
|
+
# A re-list that answered: the saved list catches up (the default
|
|
234
|
+
# re-list, HostRegistry#list_models, saved it already).
|
|
235
|
+
if fresh_ids
|
|
236
|
+
Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
|
|
237
|
+
return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
|
|
238
|
+
|
|
239
|
+
return unknown_model_message(parsed.id, host, fresh_ids)
|
|
240
|
+
end
|
|
213
241
|
|
|
242
|
+
# No answer (failed, timed out, listed nothing): the saved list stands.
|
|
214
243
|
unknown_model_message(parsed.id, host, list.ids)
|
|
215
244
|
end
|
|
216
245
|
|
|
246
|
+
# How long a miss's re-list may take before it is treated as failed: a
|
|
247
|
+
# spawn (`chi send --new --model`, delegate) warns from the saved list
|
|
248
|
+
# then, rather than wait on a slow host.
|
|
249
|
+
RE_LIST_TIMEOUT_SECONDS = 5
|
|
250
|
+
|
|
251
|
+
# A saved list younger than this is not re-listed on a miss (the other
|
|
252
|
+
# way it can be wrong: stale by CONTENT within its TTL, a one-model
|
|
253
|
+
# server reloaded with another model). A host that serves ids it never
|
|
254
|
+
# lists (a gateway's round-robin aliases) would otherwise pay the
|
|
255
|
+
# re-list's cap on every launch.
|
|
256
|
+
RELIST_AFTER_SECONDS = 10 * 60
|
|
257
|
+
|
|
258
|
+
# The default re-list: one HostRegistry, one host listed (and saved).
|
|
259
|
+
# Bounded like `chi models`' `wait:`: a thread still listing at the cap
|
|
260
|
+
# is left running and its answer ignored (only a short-lived spawn is
|
|
261
|
+
# ever here).
|
|
262
|
+
# @return [#call] (host_name, env) -> ids or nil
|
|
263
|
+
def self.default_relist(hosts)
|
|
264
|
+
lambda do |host_name, relist_env|
|
|
265
|
+
require_relative "host_registry"
|
|
266
|
+
registry = Samagotchi::HostRegistry.new(hosts_config: hosts, env: relist_env)
|
|
267
|
+
answer = nil
|
|
268
|
+
thread = Thread.new { answer = registry.list_models(host_name) }
|
|
269
|
+
thread.join(RE_LIST_TIMEOUT_SECONDS)
|
|
270
|
+
answer
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
private_class_method :default_relist
|
|
274
|
+
|
|
275
|
+
# The re-list's ids, or nil when it fails, times out or lists nothing.
|
|
276
|
+
# A failing re-list is no evidence either way: the caller warns from the
|
|
277
|
+
# saved list.
|
|
278
|
+
def self.relist_or_nil(relist, host, env)
|
|
279
|
+
ids = relist.call(host, env)
|
|
280
|
+
ids.respond_to?(:any?) && ids.any? ? ids : nil
|
|
281
|
+
rescue StandardError
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
private_class_method :relist_or_nil
|
|
285
|
+
|
|
217
286
|
# The warning for an id the host doesn't list, with up to three close
|
|
218
287
|
# ids (Config.near_names) when there are any.
|
|
219
288
|
def self.unknown_model_message(id, host, ids)
|
|
@@ -105,13 +105,15 @@ module Samagotchi
|
|
|
105
105
|
|
|
106
106
|
# Whether the session is a delegate child (Session#delegate?): a task
|
|
107
107
|
# another session handed over, not the user's own.
|
|
108
|
-
def delegate?
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
def delegate? = !!saved_session&.delegate?
|
|
109
|
+
|
|
110
|
+
# Whether the session is a fork (ctx.sessions.fork: /btw keep, a
|
|
111
|
+
# plugin's): it has a parent and isn't a delegate child, and it
|
|
112
|
+
# started from a conversation, so its first prompt isn't its first
|
|
113
|
+
# user message.
|
|
114
|
+
def fork?
|
|
115
|
+
session = saved_session
|
|
116
|
+
!session.nil? && !session.parent_id.nil? && !session.delegate?
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
# @return [String] the session's working directory
|
|
@@ -297,6 +299,16 @@ module Samagotchi
|
|
|
297
299
|
helper.respond_to?(:call) ? helper : nil
|
|
298
300
|
end
|
|
299
301
|
|
|
302
|
+
# The session's file as saved, nil without an id or a file.
|
|
303
|
+
def saved_session
|
|
304
|
+
id = session_id
|
|
305
|
+
return nil unless id
|
|
306
|
+
|
|
307
|
+
Session.load(id, state_dir: @host.state_dir&.call || Session.default_state_dir)
|
|
308
|
+
rescue ArgumentError
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
300
312
|
def deep_freeze(value)
|
|
301
313
|
case value
|
|
302
314
|
when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
|
|
@@ -197,14 +197,43 @@ module Samagotchi
|
|
|
197
197
|
# takes them at its next build (/stats names the ones it carries).
|
|
198
198
|
def model_notes_for(model, env)
|
|
199
199
|
bare = HostRegistry.new(env: env).bare_name(model)
|
|
200
|
-
|
|
201
|
-
|
|
200
|
+
key = ModelOverlay.key_for(bare)
|
|
201
|
+
notes = ModelNotes.for(name: bare, key: key, fallback_key: fallback_model_key(model, key, env),
|
|
202
|
+
muted: session_muted(env), env: env, warn: false)
|
|
202
203
|
text = PromptNote.text(notes.map { |note| PromptNote.from_note(note) })
|
|
203
204
|
text.empty? ? "none" : text
|
|
204
205
|
rescue StandardError => e
|
|
205
206
|
"(unreadable: #{e.message})"
|
|
206
207
|
end
|
|
207
208
|
|
|
209
|
+
# The overlay key read when the key has none (SystemPrompt::ModelIdentity
|
|
210
|
+
# #fallback_key): the key of the name the model was typed as, when the
|
|
211
|
+
# session chi self runs in has one (an alias), else of the name chi self
|
|
212
|
+
# reports. nil when that is the key itself. Read-only, never raising.
|
|
213
|
+
def fallback_model_key(model, key, env)
|
|
214
|
+
typed = session_typed_model(env) || model
|
|
215
|
+
typed_key = ModelOverlay.key_for(HostRegistry.new(env: env).parse_qualified_model(typed).last)
|
|
216
|
+
typed_key == key ? nil : typed_key
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The model name the session chi self runs in was typed as (an alias),
|
|
222
|
+
# its model_typed, else nil.
|
|
223
|
+
def session_typed_model(env)
|
|
224
|
+
id = env[PARENT_SESSION_ENV].to_s
|
|
225
|
+
return nil if id.empty? || !Session.valid_id?(id)
|
|
226
|
+
|
|
227
|
+
state_dir = Session.default_state_dir(env: env)
|
|
228
|
+
return nil unless Session.exist?(id, state_dir: state_dir)
|
|
229
|
+
|
|
230
|
+
session = Session.load(id, state_dir: state_dir)
|
|
231
|
+
typed = session.model_typed.to_s.strip
|
|
232
|
+
typed.empty? ? nil : typed
|
|
233
|
+
rescue StandardError
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
208
237
|
# The --mute list of the session chi self runs in (its execute exports
|
|
209
238
|
# SAMAGOTCHI_PARENT_SESSION); [] outside one.
|
|
210
239
|
def session_muted(env)
|
|
@@ -39,6 +39,9 @@ module Samagotchi
|
|
|
39
39
|
# /exit --delete: delete the session on the way out.
|
|
40
40
|
EXIT_DELETE_FLAG = "--delete"
|
|
41
41
|
|
|
42
|
+
# /help's note per listed mid_turn (the web's command list says the same).
|
|
43
|
+
MID_TURN_NOTES = { "anytime" => "mid-turn too", "depends" => "show: mid-turn too" }.freeze
|
|
44
|
+
|
|
42
45
|
# @!attribute status [Symbol] :ok, or :error when the line was refused
|
|
43
46
|
# @!attribute output [String, nil] what to tell the user
|
|
44
47
|
# @!attribute changed [Array<Symbol>] :messages and/or :model
|
|
@@ -52,21 +55,28 @@ module Samagotchi
|
|
|
52
55
|
# Puts the built-in commands into +registry+, in lookup order (!rollback
|
|
53
56
|
# before !cmd): the ones #run runs, then the UIs' own (local: Tab and
|
|
54
57
|
# help only).
|
|
58
|
+
#
|
|
59
|
+
# While a turn runs, a show form (the name alone) runs at once beside
|
|
60
|
+
# it (it only reads); setting forms and !cmd run after the turn; !rollback
|
|
61
|
+
# and /continue are refused (Commands::Registry::MID_TURN).
|
|
55
62
|
def self.register_builtins(registry)
|
|
56
63
|
registry.register(ROLLBACK_COMMAND, "discard the interrupted turn and restore the pre-turn state",
|
|
57
64
|
id: :rollback) { |_text| rollback }
|
|
58
65
|
registry.register("!", "run a shell command; its output goes into the conversation",
|
|
59
|
-
id: :shell, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
|
|
66
|
+
id: :shell, mid_turn: :queue, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
|
|
60
67
|
registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") do |text|
|
|
61
68
|
next reply("nothing to continue") unless @turn_flow.awaiting_continue?
|
|
62
69
|
|
|
63
70
|
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
64
71
|
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
|
|
65
72
|
end
|
|
66
|
-
|
|
73
|
+
# It reads the hosts only (the registry is mutex-guarded): mid-turn too.
|
|
74
|
+
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)",
|
|
75
|
+
mid_turn: ->(_text) { :anytime }) do |text|
|
|
67
76
|
reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
|
|
68
77
|
end
|
|
69
|
-
registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)"
|
|
78
|
+
registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)",
|
|
79
|
+
mid_turn: show_mid_turn(GUARDRAILS_COMMAND)) do |text|
|
|
70
80
|
guardrails(text.delete_prefix(GUARDRAILS_COMMAND).strip)
|
|
71
81
|
end
|
|
72
82
|
# It only reads the store: mid-turn too.
|
|
@@ -74,11 +84,11 @@ module Samagotchi
|
|
|
74
84
|
match: ->(text) { text.casecmp?(CONTEXT_COMMAND) }) do |_text|
|
|
75
85
|
reply(context_listing)
|
|
76
86
|
end
|
|
77
|
-
registry.register(MODEL_COMMAND, "show or switch the model",
|
|
87
|
+
registry.register(MODEL_COMMAND, "show or switch the model", mid_turn: show_mid_turn(MODEL_COMMAND),
|
|
78
88
|
match: ->(text) { text.match?(%r{\A/model(?:\s+.*)?\z}) }) { |text| model(text) }
|
|
79
|
-
#
|
|
89
|
+
# Setting it saves the session; it takes effect at the next turn's start.
|
|
80
90
|
registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
|
|
81
|
-
id: :llm_context, match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
|
|
91
|
+
id: :llm_context, mid_turn: show_mid_turn(LLM_CONTEXT_COMMAND), match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
|
|
82
92
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
83
93
|
registry.register("/stats", "show the session's stats", local: true)
|
|
84
94
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -94,6 +104,13 @@ module Samagotchi
|
|
|
94
104
|
registry
|
|
95
105
|
end
|
|
96
106
|
|
|
107
|
+
# @return [#call] line → :anytime for +name+ alone (it shows), else
|
|
108
|
+
# :queue (it sets what the next turn runs under)
|
|
109
|
+
def self.show_mid_turn(name)
|
|
110
|
+
->(text) { text == name ? :anytime : :queue }
|
|
111
|
+
end
|
|
112
|
+
private_class_method :show_mid_turn
|
|
113
|
+
|
|
97
114
|
# @param word [String] the regexp source of the exit word
|
|
98
115
|
def self.exit_match(word)
|
|
99
116
|
pattern = /\A#{word}(?:\s+#{EXIT_DELETE_FLAG})?\z/i
|
|
@@ -149,7 +166,7 @@ module Samagotchi
|
|
|
149
166
|
lines = entries.map do |entry|
|
|
150
167
|
notes = []
|
|
151
168
|
notes << entry.source unless entry.source == "core"
|
|
152
|
-
notes <<
|
|
169
|
+
notes << MID_TURN_NOTES[entry.mid_turn_label] if MID_TURN_NOTES[entry.mid_turn_label]
|
|
153
170
|
notes << (entry.uis ? "#{entry.uis.join(" and ")} only" : "terminal only") if entry.local
|
|
154
171
|
line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
|
|
155
172
|
notes.empty? ? line : "#{line} (#{notes.join("; ")})"
|
|
@@ -12,6 +12,8 @@ require_relative "config"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
14
|
require_relative "session_metrics"
|
|
15
|
+
require_relative "llm_context_strategy"
|
|
16
|
+
require_relative "host_registry"
|
|
15
17
|
require_relative "utf8_default"
|
|
16
18
|
require_relative "turn_note"
|
|
17
19
|
require_relative "owner_lock"
|
|
@@ -290,6 +292,10 @@ module Samagotchi
|
|
|
290
292
|
sd = state_dir || Session.default_state_dir
|
|
291
293
|
root = cwd && folder_path(cwd)
|
|
292
294
|
roots = {}
|
|
295
|
+
# One registry per listing, built only when a row has a saved context
|
|
296
|
+
# to count: its session's llm_context budget is what ctx_pct counts
|
|
297
|
+
# against (SessionMetrics.saved_context_pct).
|
|
298
|
+
budgets = listing_budgets
|
|
293
299
|
summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
|
|
294
300
|
include_archived: include_archived).lazy
|
|
295
301
|
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
@@ -302,7 +308,8 @@ module Samagotchi
|
|
|
302
308
|
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
|
|
303
309
|
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
304
310
|
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
305
|
-
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd)
|
|
311
|
+
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
|
|
312
|
+
budget_tokens: -> { budgets.call(s) })&.round(1),
|
|
306
313
|
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), delegate: s.delegate?, archived: s.archived,
|
|
307
314
|
scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
308
315
|
end
|
|
@@ -316,6 +323,17 @@ module Samagotchi
|
|
|
316
323
|
end
|
|
317
324
|
private_class_method :waiting_fields
|
|
318
325
|
|
|
326
|
+
# One HostRegistry for a whole listing, built on the first row that
|
|
327
|
+
# needs it (a saved context to count) and kept for the rest: resolving a
|
|
328
|
+
# session's llm_context budget reads config.yml (mtime-cached), so not
|
|
329
|
+
# one registry per row.
|
|
330
|
+
# @return [#call] (session) -> the session's budget in tokens or nil
|
|
331
|
+
def self.listing_budgets
|
|
332
|
+
registry = nil
|
|
333
|
+
->(session) { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
|
|
334
|
+
end
|
|
335
|
+
private_class_method :listing_budgets
|
|
336
|
+
|
|
319
337
|
# The sessions delegated by +parent_id+ (the `delegate` tool), newest
|
|
320
338
|
# first, as .session_summaries rows. A running one (busy) counts against
|
|
321
339
|
# session.max_children. Archived ones too.
|
|
@@ -141,22 +141,29 @@ module Samagotchi
|
|
|
141
141
|
# its saved analytics.json: a percentage, or nil when the file, the count
|
|
142
142
|
# or the window is missing (the session lists read it per row).
|
|
143
143
|
# @param session_dir [String]
|
|
144
|
+
# @param budget_tokens [Integer, Proc, nil] the session's LLM context
|
|
145
|
+
# budget; a positive one smaller than the window is what the fill is
|
|
146
|
+
# counted against (the live meter counts the same way,
|
|
147
|
+
# ContextStatus#counted_against). nil or 0: the window. A callable is
|
|
148
|
+
# resolved only when there is a context to count, so a caller can build
|
|
149
|
+
# a registry lazily.
|
|
144
150
|
# @return [Float, nil]
|
|
145
|
-
def self.saved_context_pct(session_dir)
|
|
146
|
-
saved_summary(session_dir)&.ctx_pct
|
|
151
|
+
def self.saved_context_pct(session_dir, budget_tokens: nil)
|
|
152
|
+
saved_summary(session_dir, budget_tokens: budget_tokens)&.ctx_pct
|
|
147
153
|
end
|
|
148
154
|
|
|
149
155
|
# The context's fill and the token totals from the session's saved
|
|
150
156
|
# analytics.json, read once; nil without a readable file.
|
|
151
157
|
# @param session_dir [String]
|
|
158
|
+
# @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
|
|
152
159
|
# @return [SavedSummary, nil]
|
|
153
|
-
def self.saved_summary(session_dir)
|
|
160
|
+
def self.saved_summary(session_dir, budget_tokens: nil)
|
|
154
161
|
data = JSON.parse(File.read(File.join(session_dir, "analytics.json")))
|
|
155
162
|
return nil unless data.is_a?(Hash)
|
|
156
163
|
|
|
157
164
|
tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
|
|
158
165
|
count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
|
|
159
|
-
SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.call("prompt_sum"),
|
|
166
|
+
SavedSummary.new(ctx_pct: saved_pct(data["context"], budget_tokens), prompt_sum: count.call("prompt_sum"),
|
|
160
167
|
completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
|
|
161
168
|
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
|
|
162
169
|
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
|
|
@@ -164,15 +171,25 @@ module Samagotchi
|
|
|
164
171
|
nil
|
|
165
172
|
end
|
|
166
173
|
|
|
167
|
-
def self.saved_pct(context)
|
|
174
|
+
def self.saved_pct(context, budget_tokens)
|
|
168
175
|
return nil unless context.is_a?(Hash)
|
|
169
176
|
|
|
170
177
|
used = context["used_tokens"]
|
|
171
178
|
window = context["window_tokens"]
|
|
172
179
|
return nil unless used.is_a?(Numeric) && window.is_a?(Numeric) && window.positive?
|
|
173
180
|
|
|
174
|
-
used * 100.0 / window
|
|
181
|
+
used * 100.0 / counted_against(budget_tokens, window)
|
|
175
182
|
end
|
|
183
|
+
|
|
184
|
+
# What the saved fill counts against: the window, or the budget when one
|
|
185
|
+
# is set and smaller (ContextStatus#counted_against, the live meter's
|
|
186
|
+
# rule). The budget is resolved here, not by the caller, so a caller
|
|
187
|
+
# that builds one lazily doesn't build it for a row with no context.
|
|
188
|
+
def self.counted_against(budget_tokens, window_tokens)
|
|
189
|
+
budget = budget_tokens.respond_to?(:call) ? budget_tokens.call : budget_tokens
|
|
190
|
+
budget.is_a?(Numeric) && budget.positive? ? [budget, window_tokens].min : window_tokens
|
|
191
|
+
end
|
|
192
|
+
private_class_method :counted_against
|
|
176
193
|
private_class_method :saved_pct
|
|
177
194
|
|
|
178
195
|
# One turn's tool records from the session's saved analytics.json, in
|
|
@@ -192,6 +209,22 @@ module Samagotchi
|
|
|
192
209
|
[]
|
|
193
210
|
end
|
|
194
211
|
|
|
212
|
+
# One turn's saved record from the session's analytics.json, string-keyed
|
|
213
|
+
# as saved: what a join replays the turn's failure (or cancel) line from.
|
|
214
|
+
# nil without a turn id (an older session's prompt), the file, records,
|
|
215
|
+
# or a record for the turn.
|
|
216
|
+
# @param session_dir [String]
|
|
217
|
+
# @param turn_id [String, nil]
|
|
218
|
+
# @return [Hash, nil]
|
|
219
|
+
def self.saved_turn_record(session_dir, turn_id)
|
|
220
|
+
return nil if turn_id.to_s.empty?
|
|
221
|
+
|
|
222
|
+
records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["turn_records"]
|
|
223
|
+
Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
|
|
224
|
+
rescue JSON::ParserError, SystemCallError, TypeError
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
195
228
|
# "ctx 12%" for the session lists, "" when unknown.
|
|
196
229
|
# @param pct [Float, nil]
|
|
197
230
|
# @return [String]
|
|
@@ -226,14 +226,24 @@ module Samagotchi
|
|
|
226
226
|
recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
227
227
|
(recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
|
|
228
228
|
end
|
|
229
|
+
# How full the context was after the last turn: "ctx 12%", counted
|
|
230
|
+
# against the session's llm_context budget when one is set and smaller
|
|
231
|
+
# than the window, as the live meter counts it (one registry for the
|
|
232
|
+
# whole table, built when the first row has a saved context). Blank when
|
|
233
|
+
# unknown.
|
|
234
|
+
budgets = nil
|
|
235
|
+
ctx = lambda do |s|
|
|
236
|
+
Samagotchi::SessionMetrics.context_label(
|
|
237
|
+
Samagotchi::SessionMetrics.saved_context_pct(
|
|
238
|
+
Samagotchi::Session.session_dir(s.id, state_dir: state_dir),
|
|
239
|
+
budget_tokens: -> { Samagotchi::LLMContextStrategy.session_budget(s, registry: budgets ||= Samagotchi::HostRegistry.new) }
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
end
|
|
229
243
|
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
230
244
|
row = lambda do |s|
|
|
231
245
|
flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
|
|
232
246
|
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
233
|
-
# How full the context was after the last turn: "ctx 12%", blank when unknown.
|
|
234
|
-
ctx = Samagotchi::SessionMetrics.context_label(
|
|
235
|
-
Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
236
|
-
)
|
|
237
247
|
# A question waits for an answer (chi answer, the web, chi --attach).
|
|
238
248
|
live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
|
|
239
249
|
waiting = s.waiting_question(live: !!live)
|
|
@@ -241,7 +251,7 @@ module Samagotchi
|
|
|
241
251
|
elsif waiting then "waiting"
|
|
242
252
|
else s.status
|
|
243
253
|
end
|
|
244
|
-
"#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
254
|
+
"#{s.id} #{status.ljust(8)} #{ctx.call(s).ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
245
255
|
end
|
|
246
256
|
if project
|
|
247
257
|
sessions.each { |s| @stdout.puts row.call(s) }
|
|
@@ -126,6 +126,10 @@ module Samagotchi
|
|
|
126
126
|
@early_lines = []
|
|
127
127
|
@question = nil
|
|
128
128
|
@answered_ids = Set.new
|
|
129
|
+
# Commands queued for after the running turn, any UI's: a /archive
|
|
130
|
+
# typed during the turn waits for them too (#archive_due?).
|
|
131
|
+
@queued_ids = Set.new
|
|
132
|
+
@archive_after_turn = false
|
|
129
133
|
@reader = nil
|
|
130
134
|
@first_prompt = first_prompt
|
|
131
135
|
@first_commands = Array(first_command)
|
|
@@ -188,6 +192,7 @@ module Samagotchi
|
|
|
188
192
|
when :event
|
|
189
193
|
ended = safely_handle(payload)
|
|
190
194
|
return ended if %i[closed failed unanswered].include?(ended)
|
|
195
|
+
return archive_now if archive_due?
|
|
191
196
|
|
|
192
197
|
ended = take_early_lines
|
|
193
198
|
return ended if ended
|
|
@@ -264,9 +269,11 @@ module Samagotchi
|
|
|
264
269
|
when :answer_display then @display_pending = false
|
|
265
270
|
when :context_status, :used_memories_updated then @status.take_event(event)
|
|
266
271
|
# Another client's anytime command: its line now, before the cards
|
|
267
|
-
# it shows (its command_ran comes when it's done).
|
|
272
|
+
# it shows (its command_ran comes when it's done). One queued for
|
|
273
|
+
# after the turn says so now.
|
|
268
274
|
when :command_queued
|
|
269
275
|
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
|
|
276
|
+
command_waits(event) if event[:waits] && !event[:card]
|
|
270
277
|
when :command_ran
|
|
271
278
|
command_ran(event)
|
|
272
279
|
return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
|
|
@@ -379,6 +386,7 @@ module Samagotchi
|
|
|
379
386
|
local = command_registry.lookup_local(text)&.id
|
|
380
387
|
return submit(nil) if local == :detach
|
|
381
388
|
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
389
|
+
return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
|
|
382
390
|
return exit_and_archive if local == :archive
|
|
383
391
|
|
|
384
392
|
if @continue_offer
|
|
@@ -446,6 +454,24 @@ module Samagotchi
|
|
|
446
454
|
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
|
|
447
455
|
end
|
|
448
456
|
|
|
457
|
+
# /archive during a turn (D2): it waits for the turn's end and the
|
|
458
|
+
# commands queued behind it (#archive_due?), then runs.
|
|
459
|
+
# @return [nil]
|
|
460
|
+
def archive_after_turn
|
|
461
|
+
@archive_after_turn = true
|
|
462
|
+
@screen.commit("(archives after this turn)")
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
|
|
467
|
+
|
|
468
|
+
# @return [Symbol] :detached
|
|
469
|
+
def archive_now
|
|
470
|
+
@archive_after_turn = false
|
|
471
|
+
exit_and_archive
|
|
472
|
+
:detached
|
|
473
|
+
end
|
|
474
|
+
|
|
449
475
|
# /archive: the worker is asked to exit as for /exit, then the session
|
|
450
476
|
# is archived. A worker staying up for another UI (or reminders) is
|
|
451
477
|
# stopped by the archive, as the web's archive stops it; a turn
|
|
@@ -546,13 +572,36 @@ module Samagotchi
|
|
|
546
572
|
"worker unreachable: #{head.downcase}"
|
|
547
573
|
end
|
|
548
574
|
|
|
575
|
+
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
576
|
+
|
|
577
|
+
# A command queued for after the running turn (command_queued with
|
|
578
|
+
# waits): its line now (another client's) and a note; its command_ran
|
|
579
|
+
# has no line again (queued: true).
|
|
580
|
+
def command_waits(event)
|
|
581
|
+
@queued_ids << event[:command_id]
|
|
582
|
+
if own?(event[:client_id])
|
|
583
|
+
first = event[:command_id] == @first_command_id
|
|
584
|
+
return @screen.commit(first ? first_command_queued : QUEUED_NOTE)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
@screen.commit("#{prompt_line(event[:client_id], event[:line])} #{QUEUED_NOTE}")
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def first_command_queued
|
|
591
|
+
flag = @first_command_line.to_s.start_with?("/model") ? "--model" : "--llm-context"
|
|
592
|
+
"(#{flag} queued: applies after the running turn)"
|
|
593
|
+
end
|
|
594
|
+
|
|
549
595
|
def command_ran(event)
|
|
550
596
|
@open_ids.delete(event[:command_id])
|
|
551
|
-
@
|
|
597
|
+
@queued_ids.delete(event[:command_id])
|
|
598
|
+
unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
|
|
599
|
+
@screen.commit(prompt_line(event[:client_id], event[:line]))
|
|
600
|
+
end
|
|
552
601
|
output = event[:output].to_s
|
|
553
602
|
if output.empty?
|
|
554
603
|
nil
|
|
555
|
-
elsif event[:status]
|
|
604
|
+
elsif %w[busy dropped].include?(event[:status]) || PromptHistory.shell_line?(event[:line])
|
|
556
605
|
@screen.commit(output)
|
|
557
606
|
put_back_busy_command(event) if event[:status] == "busy"
|
|
558
607
|
else
|
|
@@ -992,6 +1041,14 @@ module Samagotchi
|
|
|
992
1041
|
@shown_enqueued << entry[:enqueued_id]
|
|
993
1042
|
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
|
|
994
1043
|
end
|
|
1044
|
+
# Commands waiting for the turn's end (their command_ran prints no
|
|
1045
|
+
# line); a resync prints only the ones it hadn't shown.
|
|
1046
|
+
shown = @queued_ids.dup
|
|
1047
|
+
@queued_ids.clear
|
|
1048
|
+
Array(snapshot[:queued_commands]).each do |entry|
|
|
1049
|
+
@queued_ids << entry[:command_id]
|
|
1050
|
+
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:line])}") unless shown.include?(entry[:command_id])
|
|
1051
|
+
end
|
|
995
1052
|
return send_first_command unless @first_commands.empty?
|
|
996
1053
|
|
|
997
1054
|
send_first_prompt
|
|
@@ -1076,10 +1133,18 @@ module Samagotchi
|
|
|
1076
1133
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1077
1134
|
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1078
1135
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
1136
|
+
failed = join_failed_record(exchange[last_user])
|
|
1079
1137
|
# A turn that ended with no answer: its notice, as live.
|
|
1080
1138
|
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
1081
1139
|
if empty
|
|
1082
1140
|
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
|
|
1141
|
+
elsif failed
|
|
1142
|
+
# A failed turn whose steps stayed: the line the live view left,
|
|
1143
|
+
# and no step text as if it were the answer (the web keeps a
|
|
1144
|
+
# failed turn's texts as steps too).
|
|
1145
|
+
failure = failed["failure"]
|
|
1146
|
+
@screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
|
|
1147
|
+
@screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
|
|
1083
1148
|
else
|
|
1084
1149
|
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
1085
1150
|
# be only a tool call.
|
|
@@ -1119,6 +1184,23 @@ module Samagotchi
|
|
|
1119
1184
|
[]
|
|
1120
1185
|
end
|
|
1121
1186
|
|
|
1187
|
+
# The turn's saved record when it failed with the fields its line is
|
|
1188
|
+
# made of (SessionMetrics#failure_fields), else nil: a completed,
|
|
1189
|
+
# canceled or older turn — or one saved before the failure field —
|
|
1190
|
+
# draws its answer as before.
|
|
1191
|
+
def join_failed_record(prompt)
|
|
1192
|
+
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
|
|
1193
|
+
prompt[:turn_id] || prompt["turn_id"])
|
|
1194
|
+
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
1195
|
+
|
|
1196
|
+
failure = record["failure"]
|
|
1197
|
+
return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
|
|
1198
|
+
|
|
1199
|
+
record
|
|
1200
|
+
rescue Session::InvalidId
|
|
1201
|
+
nil
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1122
1204
|
# The duration of +part+'s call: the next record of its tool, in call
|
|
1123
1205
|
# order, consumed with the ones it skipped. By tool and order, not by
|
|
1124
1206
|
# iteration: an empty retry spends an iteration that left no message.
|
|
@@ -1222,7 +1304,8 @@ module Samagotchi
|
|
|
1222
1304
|
# hook's notice, a retry's line), as they were drawn live.
|
|
1223
1305
|
# Questions are left out: the pending one is asked after the
|
|
1224
1306
|
# replay, answered ones showed no line.
|
|
1225
|
-
when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut
|
|
1307
|
+
when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
|
|
1308
|
+
@renderer.call(event)
|
|
1226
1309
|
end
|
|
1227
1310
|
end
|
|
1228
1311
|
@view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
|
|
@@ -83,6 +83,9 @@ module Samagotchi
|
|
|
83
83
|
when :steer_cut
|
|
84
84
|
@view.finish_thinking_spinner
|
|
85
85
|
@view.print_line(@view.format_steer_cut_line(event))
|
|
86
|
+
when :llm_context_edited
|
|
87
|
+
@view.finish_thinking_spinner
|
|
88
|
+
@view.print_line(@view.format_llm_context_line(event))
|
|
86
89
|
when :turn_completed
|
|
87
90
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
88
91
|
when :turn_canceled
|