samagotchi 0.44.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 +70 -1
- data/docs/cli.md +29 -7
- data/docs/context.md +2 -1
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- 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/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- 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 +85 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +19 -1
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +7 -9
- data/lib/samagotchi/web/public/app.js +23 -15
- 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 +3 -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 +16 -4
- 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 +1 -1
|
@@ -235,6 +235,26 @@ module Samagotchi
|
|
|
235
235
|
end
|
|
236
236
|
end
|
|
237
237
|
|
|
238
|
+
# List one host's models and save them to ModelListStore: what
|
|
239
|
+
# ModelProfile.model_warning re-lists on a miss (a fresh saved list that
|
|
240
|
+
# lacks the id: the server may have been reloaded with another model),
|
|
241
|
+
# so the saved list catches up at spawn time. Only that host is asked,
|
|
242
|
+
# and never through the cache (a fresh listing is the point).
|
|
243
|
+
# @param name [String, Symbol, nil] a host name; anything else lists
|
|
244
|
+
# nothing
|
|
245
|
+
# @return [Array<String>, nil] the ids the host lists now, nil for an
|
|
246
|
+
# unknown name or when the host fails to list (nothing is saved then:
|
|
247
|
+
# a host that is down keeps the ids we know)
|
|
248
|
+
def list_models(name)
|
|
249
|
+
entry = name.nil? ? nil : find_entry(name)
|
|
250
|
+
return nil unless entry
|
|
251
|
+
|
|
252
|
+
save_model_list(entry.name, list_models_for(entry))&.ids
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
Log.warn(:model, "list_failed", host: name.to_s, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
238
258
|
def client_for_model(raw_model)
|
|
239
259
|
host_entry, bare = host_for_model(raw_model)
|
|
240
260
|
[client_for(host_entry), bare, host_entry]
|
|
@@ -293,9 +313,7 @@ module Samagotchi
|
|
|
293
313
|
models = list_models_for(entry)
|
|
294
314
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
|
|
295
315
|
@mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
|
|
296
|
-
|
|
297
|
-
# (`chi send --new --model`, delegate) checks an id against it.
|
|
298
|
-
ModelListStore.save(name, models.map(&:id))
|
|
316
|
+
save_model_list(name, models)
|
|
299
317
|
rescue StandardError => e
|
|
300
318
|
Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
301
319
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
|
|
@@ -350,6 +368,13 @@ module Samagotchi
|
|
|
350
368
|
|
|
351
369
|
private
|
|
352
370
|
|
|
371
|
+
# Save a host's just-listed models on disk, for the processes that spawn
|
|
372
|
+
# a worker without listing (`chi send --new --model`, delegate):
|
|
373
|
+
# ModelListStore is what they check an id against.
|
|
374
|
+
def save_model_list(name, models)
|
|
375
|
+
ModelListStore.save(name, Array(models).map(&:id))
|
|
376
|
+
end
|
|
377
|
+
|
|
353
378
|
def fresh_list(name, entry)
|
|
354
379
|
@mutex.synchronize do
|
|
355
380
|
cached = @host_lists[name]
|
|
@@ -167,6 +167,43 @@ module Samagotchi
|
|
|
167
167
|
budget_tokens: budget_origin)
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
# A session's LLM context strategy, apply rule and budget, each with
|
|
171
|
+
# where it came from (Explained#summary), as its worker would resolve
|
|
172
|
+
# them; nil when they can't be (an unreadable config, a model that
|
|
173
|
+
# resolves nowhere). Resolves from config and the registry only: no
|
|
174
|
+
# network, no model call. What a session page, a session list's ctx %
|
|
175
|
+
# and a spawn-time check all read.
|
|
176
|
+
# @param session [Session, nil]
|
|
177
|
+
# @param registry [HostRegistry, nil] the hosts to resolve against;
|
|
178
|
+
# HostRegistry.new by default (what the caller usually has)
|
|
179
|
+
# @return [Hash, nil] Explained#summary, symbol keys
|
|
180
|
+
def for_session(session, registry: nil)
|
|
181
|
+
return nil if session.nil?
|
|
182
|
+
|
|
183
|
+
require_relative "config"
|
|
184
|
+
registry ||= begin
|
|
185
|
+
require_relative "host_registry"
|
|
186
|
+
HostRegistry.new
|
|
187
|
+
end
|
|
188
|
+
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
189
|
+
target = registry.resolve(name)
|
|
190
|
+
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
191
|
+
own = session.llm_context
|
|
192
|
+
explain(target, names: names, **(own&.resolve_args || {})).summary(own)
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The budget a session's turns run under (for_session's :budget_tokens),
|
|
198
|
+
# nil when it is off or can't be resolved. What a session list's ctx %
|
|
199
|
+
# counts against, so a saved session's fill reads as the live meter.
|
|
200
|
+
# @param session [Session, nil]
|
|
201
|
+
# @param registry [HostRegistry, nil] see .for_session
|
|
202
|
+
# @return [Integer, nil]
|
|
203
|
+
def session_budget(session, registry: nil)
|
|
204
|
+
for_session(session, registry: registry)&.fetch(:budget_tokens, nil)
|
|
205
|
+
end
|
|
206
|
+
|
|
170
207
|
# [the budget, its Origin]: the session's own first (0 there is off,
|
|
171
208
|
# whatever the model says).
|
|
172
209
|
def budget_for(target, names, models, session_budget)
|
|
@@ -63,7 +63,8 @@ module Samagotchi
|
|
|
63
63
|
files(scope, env).filter_map do |path|
|
|
64
64
|
next unless seen.add?(File.expand_path(path))
|
|
65
65
|
|
|
66
|
-
note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once
|
|
66
|
+
note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once,
|
|
67
|
+
env: env)
|
|
67
68
|
end
|
|
68
69
|
end
|
|
69
70
|
warn_sizes(notes) if warn
|
|
@@ -110,7 +111,7 @@ module Samagotchi
|
|
|
110
111
|
[]
|
|
111
112
|
end
|
|
112
113
|
|
|
113
|
-
def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
|
|
114
|
+
def note_at(path, scope, name:, key:, fallback_key:, muted:, small:, env: ENV)
|
|
114
115
|
stem = File.basename(path, ".md")
|
|
115
116
|
return nil if dotted?(path, stem)
|
|
116
117
|
return nil if MutedMemories.muted?(stem, muted)
|
|
@@ -122,7 +123,7 @@ module Samagotchi
|
|
|
122
123
|
body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
|
|
123
124
|
return nil if body.empty?
|
|
124
125
|
|
|
125
|
-
body = with_overlay(body, stem, scope, [key, fallback_key])
|
|
126
|
+
body = with_overlay(body, stem, scope, [key, fallback_key], env: env)
|
|
126
127
|
|
|
127
128
|
Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
|
|
128
129
|
rescue StandardError
|
|
@@ -131,10 +132,11 @@ module Samagotchi
|
|
|
131
132
|
|
|
132
133
|
# The note's model overlay appended as memory_read appends one: the
|
|
133
134
|
# key's, else the fallback key's. The file is read by its own path, not
|
|
134
|
-
# by name through memory_read (whose names are comma lists).
|
|
135
|
-
|
|
135
|
+
# by name through memory_read (whose names are comma lists). +env+: the
|
|
136
|
+
# XDG env the scope's dir (and so the overlay) resolves from.
|
|
137
|
+
def with_overlay(body, stem, scope, keys, env: ENV)
|
|
136
138
|
keys.compact.each do |overlay_key|
|
|
137
|
-
overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
|
|
139
|
+
overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope, env: env)
|
|
138
140
|
next unless overlay && File.file?(overlay)
|
|
139
141
|
|
|
140
142
|
return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
|
|
@@ -38,10 +38,11 @@ module Samagotchi
|
|
|
38
38
|
|
|
39
39
|
# Return the expected file path for an overlay in the given scope.
|
|
40
40
|
# Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
|
|
41
|
-
|
|
41
|
+
# @param env [Hash] the XDG env the scope's memories dir resolves from
|
|
42
|
+
def self.overlay_path_for(name, key, scope, env: ENV)
|
|
42
43
|
return nil unless key && !key.to_s.strip.empty?
|
|
43
44
|
|
|
44
|
-
File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
|
|
45
|
+
File.join(Tools::MemoryRead.memories_dir(scope, env: env), "#{name}.#{key.to_s.strip}.md")
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
|
|
@@ -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) }
|