samagotchi 0.44.0 → 0.46.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 +120 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +43 -12
- data/docs/configuration.md +102 -5
- data/docs/context.md +2 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +9 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +19 -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/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/config.rb +57 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/host_model.rb +116 -0
- data/lib/samagotchi/host_registry.rb +43 -12
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +89 -7
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_catalog.rb +17 -12
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +74 -3
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +33 -3
- data/lib/samagotchi/session_commands.rb +47 -21
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +79 -11
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +97 -12
- data/lib/samagotchi/tools/delegate_wait.rb +5 -4
- data/lib/samagotchi/tools/task_runtime.rb +11 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -11
- data/lib/samagotchi/web/public/app.js +30 -18
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +62 -22
- data/lib/samagotchi/web/public/format.js +7 -2
- 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/model_pick.js +23 -3
- data/lib/samagotchi/web/public/model_picker.js +5 -3
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +19 -6
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +172 -21
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +4 -1
|
@@ -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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A model's price on a host (hosts.<name>.models.<id>.price), in USD per
|
|
5
|
+
# 1M tokens: what a generation's cost is estimated from when the provider
|
|
6
|
+
# reports none (SessionMetrics, cost_estimate). An unset cache rate is the
|
|
7
|
+
# input rate: that overstates cached reads, and understates Anthropic
|
|
8
|
+
# cache writes (billed 1.25x input for the 5 min cache, 2x for 1 h).
|
|
9
|
+
ModelPrice = Data.define(:input, :cache_read, :cache_write, :output) do
|
|
10
|
+
# @param raw [Hash, nil] the price as written
|
|
11
|
+
# @param where [String] for the warning
|
|
12
|
+
# @return [ModelPrice, nil] nil when unset or invalid (warned once)
|
|
13
|
+
def self.parse(raw, where)
|
|
14
|
+
return nil if raw.nil?
|
|
15
|
+
|
|
16
|
+
values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
|
|
17
|
+
valid = values && values.key?("input") && values.key?("output") &&
|
|
18
|
+
# YAML's .inf and .nan are Floats too: JSON can't carry them
|
|
19
|
+
# (hosts_json_for_env would drop every host) and NaN poisons the sums.
|
|
20
|
+
values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
|
|
21
|
+
unless valid
|
|
22
|
+
warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
new(input: values["input"], cache_read: values.fetch("cache_read", values["input"]),
|
|
26
|
+
cache_write: values.fetch("cache_write", values["input"]), output: values["output"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# config.rb requires model_price through host_model, so config is
|
|
30
|
+
# required here only when a warning needs it.
|
|
31
|
+
def self.warn_once(message)
|
|
32
|
+
require_relative "config"
|
|
33
|
+
ConfigFile.warn_once(message)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :warn_once
|
|
36
|
+
|
|
37
|
+
# USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
|
|
38
|
+
# the cached reads and the cache writes (OpenRouter keeps that for
|
|
39
|
+
# Anthropic models), and reasoning is inside +completion_tokens+.
|
|
40
|
+
# llama.cpp's native timings.prompt_n leaves its cache_n out, so the
|
|
41
|
+
# uncached part is clamped at 0: an undercount, harmless because priced
|
|
42
|
+
# models are remote.
|
|
43
|
+
def cost(prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
|
|
44
|
+
uncached = (prompt_tokens - cached_tokens - cache_write_tokens).clamp(0..)
|
|
45
|
+
((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
|
|
46
|
+
(completion_tokens * output)) / 1e6
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_config = to_h.transform_keys(&:to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The keys a price may hold (config validation checks them too).
|
|
53
|
+
ModelPrice::KEYS = %w[input cache_read cache_write output].freeze
|
|
54
|
+
end
|
|
@@ -187,19 +187,31 @@ 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
|
|
194
|
-
# goes to whichever host lists it (HostRegistry#host_for_model), so no
|
|
193
|
+
# goes to whichever host lists or declares it (HostRegistry#host_for_model), so no
|
|
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)
|
|
@@ -207,13 +219,72 @@ module Samagotchi
|
|
|
207
219
|
parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
|
|
208
220
|
host = parsed.host_name
|
|
209
221
|
return nil unless host
|
|
222
|
+
# Declared under hosts.<name>.models: served whatever the host lists.
|
|
223
|
+
return nil if hosts[host.to_s.strip.downcase]&.dig(:models)&.key?(parsed.id.to_s.strip.downcase)
|
|
210
224
|
|
|
211
225
|
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
212
226
|
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
227
|
+
# A saved list this recent is taken at its word. Re-listing every miss
|
|
228
|
+
# would make each launch of an id a host serves but never lists (a
|
|
229
|
+
# gateway's round-robin aliases) pay the re-list's cap and warn
|
|
230
|
+
# anyway; a list that old is the case worth catching (a one-model
|
|
231
|
+
# server reloaded with another model).
|
|
232
|
+
return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
|
|
233
|
+
|
|
234
|
+
fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
|
|
235
|
+
# A re-list that answered: the saved list catches up (the default
|
|
236
|
+
# re-list, HostRegistry#list_models, saved it already).
|
|
237
|
+
if fresh_ids
|
|
238
|
+
Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
|
|
239
|
+
return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
|
|
240
|
+
|
|
241
|
+
return unknown_model_message(parsed.id, host, fresh_ids)
|
|
242
|
+
end
|
|
213
243
|
|
|
244
|
+
# No answer (failed, timed out, listed nothing): the saved list stands.
|
|
214
245
|
unknown_model_message(parsed.id, host, list.ids)
|
|
215
246
|
end
|
|
216
247
|
|
|
248
|
+
# How long a miss's re-list may take before it is treated as failed: a
|
|
249
|
+
# spawn (`chi send --new --model`, delegate) warns from the saved list
|
|
250
|
+
# then, rather than wait on a slow host.
|
|
251
|
+
RE_LIST_TIMEOUT_SECONDS = 5
|
|
252
|
+
|
|
253
|
+
# A saved list younger than this is not re-listed on a miss (the other
|
|
254
|
+
# way it can be wrong: stale by CONTENT within its TTL, a one-model
|
|
255
|
+
# server reloaded with another model). A host that serves ids it never
|
|
256
|
+
# lists (a gateway's round-robin aliases) would otherwise pay the
|
|
257
|
+
# re-list's cap on every launch.
|
|
258
|
+
RELIST_AFTER_SECONDS = 10 * 60
|
|
259
|
+
|
|
260
|
+
# The default re-list: one HostRegistry, one host listed (and saved).
|
|
261
|
+
# Bounded like `chi models`' `wait:`: a thread still listing at the cap
|
|
262
|
+
# is left running and its answer ignored (only a short-lived spawn is
|
|
263
|
+
# ever here).
|
|
264
|
+
# @return [#call] (host_name, env) -> ids or nil
|
|
265
|
+
def self.default_relist(hosts)
|
|
266
|
+
lambda do |host_name, relist_env|
|
|
267
|
+
require_relative "host_registry"
|
|
268
|
+
registry = Samagotchi::HostRegistry.new(hosts_config: hosts, env: relist_env)
|
|
269
|
+
answer = nil
|
|
270
|
+
thread = Thread.new { answer = registry.list_models(host_name) }
|
|
271
|
+
thread.join(RE_LIST_TIMEOUT_SECONDS)
|
|
272
|
+
answer
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
private_class_method :default_relist
|
|
276
|
+
|
|
277
|
+
# The re-list's ids, or nil when it fails, times out or lists nothing.
|
|
278
|
+
# A failing re-list is no evidence either way: the caller warns from the
|
|
279
|
+
# saved list.
|
|
280
|
+
def self.relist_or_nil(relist, host, env)
|
|
281
|
+
ids = relist.call(host, env)
|
|
282
|
+
ids.respond_to?(:any?) && ids.any? ? ids : nil
|
|
283
|
+
rescue StandardError
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
private_class_method :relist_or_nil
|
|
287
|
+
|
|
217
288
|
# The warning for an id the host doesn't list, with up to three close
|
|
218
289
|
# ids (Config.near_names) when there are any.
|
|
219
290
|
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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# A command run in a process group of its own, so a stop reaches what it
|
|
5
7
|
# started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
|
|
@@ -51,12 +53,44 @@ module Samagotchi
|
|
|
51
53
|
|
|
52
54
|
# Whether +pid+ runs and leads its own group, as #spawn's process does
|
|
53
55
|
# (not, then, a reused pid in some other group).
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
# @param started [String, nil] the start time #start_time read when chi
|
|
57
|
+
# spawned it: a process that started at another time is one that
|
|
58
|
+
# reused the pid, even as a group leader. nil checks the group only (a
|
|
59
|
+
# record from an older chi), and so does a time that can't be read
|
|
60
|
+
# now (a ps that failed once must not disown a live task).
|
|
61
|
+
# @param start_time [#call] (pid) the reader, #start_time by default
|
|
62
|
+
def leader?(pid, started: nil, start_time: method(:start_time))
|
|
63
|
+
return false unless signalable?(pid) && Process.getpgid(pid) == pid
|
|
64
|
+
return true if started.nil?
|
|
65
|
+
|
|
66
|
+
now = start_time.call(pid)
|
|
67
|
+
now.nil? || now == started
|
|
56
68
|
rescue Errno::ESRCH, Errno::EPERM
|
|
57
69
|
false
|
|
58
70
|
end
|
|
59
71
|
|
|
72
|
+
# When +pid+'s process started, as an opaque string to compare: the
|
|
73
|
+
# start in clock ticks from /proc/PID/stat on Linux, ps's lstart (to the
|
|
74
|
+
# second, in UTC) elsewhere. nil when it isn't running or can't be read.
|
|
75
|
+
# @return [String, nil]
|
|
76
|
+
def start_time(pid)
|
|
77
|
+
return nil unless signalable?(pid)
|
|
78
|
+
|
|
79
|
+
stat = "/proc/#{pid}/stat"
|
|
80
|
+
return proc_start_time(File.read(stat)) if File.exist?(stat)
|
|
81
|
+
|
|
82
|
+
out, status = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC" }, "ps", "-o", "lstart=", "-p", pid.to_s,
|
|
83
|
+
err: File::NULL)
|
|
84
|
+
time = out.strip
|
|
85
|
+
status.success? && !time.empty? ? time : nil
|
|
86
|
+
rescue SystemCallError, IOError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Field 22 (starttime); the command name (field 2) may hold spaces and
|
|
91
|
+
# parentheses, so the fields are counted after its last ")".
|
|
92
|
+
def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
|
|
93
|
+
|
|
60
94
|
# Whether +pid+ is still running (or a zombie not reaped yet).
|
|
61
95
|
# @raise [ArgumentError] unless #signalable?
|
|
62
96
|
def alive?(pid)
|
|
@@ -69,26 +103,39 @@ module Samagotchi
|
|
|
69
103
|
true
|
|
70
104
|
end
|
|
71
105
|
|
|
72
|
-
#
|
|
106
|
+
# How long #stop waits for +stopped+ after the KILL by default.
|
|
107
|
+
KILL_WAIT_SEC = 1.0
|
|
108
|
+
|
|
109
|
+
# TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
|
|
110
|
+
# waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
|
|
111
|
+
# done, when kill returns).
|
|
73
112
|
# @param stopped [#call] true once it has stopped (default: the leader
|
|
74
113
|
# is gone)
|
|
75
114
|
# @param wait [#call] (seconds) between two checks (default: a sleep)
|
|
76
115
|
# @param signal_options [Hash] for #signal (leader:, quiet:)
|
|
77
116
|
# @return [Boolean] true when it stopped within the grace (no KILL sent)
|
|
78
117
|
# @raise [ArgumentError] unless #signalable?
|
|
79
|
-
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
118
|
+
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
119
|
+
kill_wait: KILL_WAIT_SEC, **signal_options)
|
|
80
120
|
check!(pgid)
|
|
81
121
|
signal(pgid, "TERM", **signal_options)
|
|
82
|
-
|
|
83
|
-
until (done = stopped.call) || monotonic >= deadline
|
|
84
|
-
wait.call(poll)
|
|
85
|
-
end
|
|
86
|
-
return true if done
|
|
122
|
+
return true if wait_for(stopped, grace, poll, wait)
|
|
87
123
|
|
|
88
124
|
signal(pgid, "KILL", **signal_options)
|
|
125
|
+
wait_for(stopped, kill_wait, poll, wait)
|
|
89
126
|
false
|
|
90
127
|
end
|
|
91
128
|
|
|
129
|
+
# Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
|
|
130
|
+
# @return [Boolean] whether it stopped
|
|
131
|
+
def wait_for(stopped, seconds, poll, wait)
|
|
132
|
+
deadline = monotonic + seconds
|
|
133
|
+
until (done = stopped.call) || monotonic >= deadline
|
|
134
|
+
wait.call(poll)
|
|
135
|
+
end
|
|
136
|
+
done ? true : false
|
|
137
|
+
end
|
|
138
|
+
|
|
92
139
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
93
140
|
|
|
94
141
|
# Reads a pipe in chunks on a thread of its own, so the output read so
|
|
@@ -49,7 +49,7 @@ module Samagotchi
|
|
|
49
49
|
elsif !IdleTarget.host_named?(**settings)
|
|
50
50
|
# A model naming no host goes where a bare --model goes
|
|
51
51
|
# (HostRegistry#host_for_model: the default host unless
|
|
52
|
-
# another
|
|
52
|
+
# another lists or declares it), resolved at each attempt.
|
|
53
53
|
-> { IdleTarget.resolve(**settings) }
|
|
54
54
|
else
|
|
55
55
|
fixed = fixed_target(settings)
|
|
@@ -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)
|
|
@@ -265,7 +294,8 @@ module Samagotchi
|
|
|
265
294
|
served = ServedModel.from_props(props)
|
|
266
295
|
return "unknown (no answer from the server's /props)" unless served
|
|
267
296
|
|
|
268
|
-
|
|
297
|
+
expected = entry.models&.dig(bare.to_s.downcase)&.serves?(served)
|
|
298
|
+
ServedModel.differs?(bare, served) && !expected ? "#{served} (not #{bare}: the server serves its own model)" : served
|
|
269
299
|
rescue StandardError => e
|
|
270
300
|
"unknown (#{e.class})"
|
|
271
301
|
end
|
|
@@ -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("; ")})"
|
|
@@ -475,10 +492,11 @@ module Samagotchi
|
|
|
475
492
|
end
|
|
476
493
|
end
|
|
477
494
|
|
|
478
|
-
# "; served: <name>" when the server serves another model than asked
|
|
495
|
+
# "; served: <name>" when the server serves another model than asked
|
|
496
|
+
# and its host's served: doesn't expect it.
|
|
479
497
|
def served_note
|
|
480
498
|
served, asked = @engine.served_model
|
|
481
|
-
ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
|
|
499
|
+
ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
|
|
482
500
|
rescue StandardError
|
|
483
501
|
""
|
|
484
502
|
end
|
|
@@ -550,6 +568,7 @@ module Samagotchi
|
|
|
550
568
|
|
|
551
569
|
seen = Set.new
|
|
552
570
|
lines = []
|
|
571
|
+
host_rows = HostModel.rows(results, registry.entries)
|
|
553
572
|
# Sort hosts for deterministic output
|
|
554
573
|
results.keys.sort.each do |hname|
|
|
555
574
|
data = results[hname]
|
|
@@ -558,29 +577,35 @@ module Samagotchi
|
|
|
558
577
|
lines << "#{host_label} — unreachable: #{data[:error]}"
|
|
559
578
|
next
|
|
560
579
|
end
|
|
561
|
-
|
|
562
|
-
|
|
580
|
+
# Declared ids (hosts.<name>.models) first, as "(config)", never cut
|
|
581
|
+
# by MODELS_PER_HOST.
|
|
582
|
+
rows = host_rows[hname]
|
|
583
|
+
if rows.empty?
|
|
563
584
|
lines << "#{host_label} — no models discovered"
|
|
564
585
|
next
|
|
565
586
|
end
|
|
587
|
+
declared = []
|
|
566
588
|
shown = []
|
|
567
|
-
batch_variants = batch_variant_ids(
|
|
568
|
-
|
|
569
|
-
identifier =
|
|
589
|
+
batch_variants = batch_variant_ids(rows, needle)
|
|
590
|
+
rows.each do |row|
|
|
591
|
+
identifier = row.id.to_s.empty? ? "unknown" : row.id
|
|
570
592
|
seen << identifier.to_s.downcase
|
|
571
593
|
# also track host-qualified seen for orphan logic
|
|
572
594
|
seen << "#{hname}:#{identifier}".downcase
|
|
573
|
-
next if batch_variants.include?(identifier)
|
|
595
|
+
next if !row.configured && batch_variants.include?(identifier)
|
|
596
|
+
next unless needle.empty? || identifier.to_s.downcase.include?(needle)
|
|
574
597
|
|
|
575
|
-
shown << [
|
|
598
|
+
(row.configured ? declared : shown) << [row, identifier]
|
|
576
599
|
end
|
|
577
|
-
next if shown.empty?
|
|
600
|
+
next if declared.empty? && shown.empty?
|
|
578
601
|
|
|
579
602
|
lines << "#{host_label}:"
|
|
580
603
|
hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
|
|
581
|
-
shown.first(shown.size - hidden).each do |
|
|
582
|
-
|
|
604
|
+
(declared + shown.first(shown.size - hidden)).each do |row, identifier|
|
|
605
|
+
raw = row.info&.raw || {}
|
|
606
|
+
raw_status = raw["status"] || raw[:status]
|
|
583
607
|
status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
|
|
608
|
+
status = ["config", status].reject { |s| s.to_s.empty? }.join(", ") if row.configured
|
|
584
609
|
base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
|
|
585
610
|
alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
|
|
586
611
|
alias_list.uniq!
|
|
@@ -614,9 +639,10 @@ module Samagotchi
|
|
|
614
639
|
def batch_variant_ids(models, needle)
|
|
615
640
|
return Set.new if needle.include?("batch")
|
|
616
641
|
|
|
642
|
+
# Any case: a declared id keeps its own spelling (HostModel.rows).
|
|
617
643
|
ids = models.map { |entry| entry.id.to_s }
|
|
618
|
-
plain = ids.to_set
|
|
619
|
-
ids.select { |id| id.end_with?(":batch") && plain.include?(id.delete_suffix(":batch")) }.to_set
|
|
644
|
+
plain = ids.to_set(&:downcase)
|
|
645
|
+
ids.select { |id| id.downcase.end_with?(":batch") && plain.include?(id.downcase.delete_suffix(":batch")) }.to_set
|
|
620
646
|
end
|
|
621
647
|
end
|
|
622
648
|
end
|
|
@@ -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.
|