samagotchi 0.70.0 → 0.72.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 +83 -1
- data/docs/architecture.md +16 -10
- data/docs/cli.md +7 -4
- data/docs/configuration.md +24 -14
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/engine.md +38 -13
- data/docs/internals/llm-context-live.md +1 -1
- data/docs/internals/prompt-caching.md +6 -0
- data/docs/plugins.md +18 -7
- data/docs/sessions.md +4 -2
- data/docs/testing.md +12 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge.rb +5 -2
- data/lib/samagotchi/broadcast/scope_card.rb +2 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
- data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +7 -25
- data/lib/samagotchi/context_note.rb +2 -6
- data/lib/samagotchi/effective_model.rb +1 -1
- data/lib/samagotchi/engine.rb +91 -213
- data/lib/samagotchi/engine_peers.rb +2 -0
- data/lib/samagotchi/event_routes.rb +83 -0
- data/lib/samagotchi/hook_routes.rb +16 -37
- data/lib/samagotchi/host_config.rb +1 -1
- data/lib/samagotchi/host_keys.rb +0 -2
- data/lib/samagotchi/paths.rb +13 -0
- data/lib/samagotchi/plugin/api.rb +11 -5
- data/lib/samagotchi/plugin/peer.rb +5 -8
- data/lib/samagotchi/prompt_files.rb +2 -4
- data/lib/samagotchi/session.rb +2 -1
- data/lib/samagotchi/session_manager.rb +6 -16
- data/lib/samagotchi/session_model_settings.rb +227 -0
- data/lib/samagotchi/session_setup.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +12 -3
- data/lib/samagotchi/system_prompt.rb +18 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +36 -15
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
- data/lib/samagotchi/tool_activity.rb +20 -1
- data/lib/samagotchi/tool_declarations.rb +4 -0
- data/lib/samagotchi/tool_output_cut.rb +11 -2
- data/lib/samagotchi/toolkit.rb +60 -22
- data/lib/samagotchi/toolkit_binding.rb +7 -5
- data/lib/samagotchi/toolkit_command.rb +28 -5
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +32 -6
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/tools/peers.rb +5 -2
- data/lib/samagotchi/tools/register_reminder.rb +1 -0
- data/lib/samagotchi/tools/registry.rb +13 -2
- data/lib/samagotchi/tools/task_wait.rb +4 -6
- data/lib/samagotchi/tools/tool_index.rb +15 -4
- data/lib/samagotchi/turn_note.rb +4 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +18 -17
- data/lib/samagotchi/web/message_parts.rb +1 -1
- data/lib/samagotchi/web/public/answer_refs.js +1 -0
- data/lib/samagotchi/web/public/app.js +20 -18
- data/lib/samagotchi/web/public/bubbles.js +1 -1
- data/lib/samagotchi/web/public/cards_place.js +6 -10
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +3 -1
- data/lib/samagotchi/web/public/composer.js +13 -6
- data/lib/samagotchi/web/public/context_chips.js +25 -12
- data/lib/samagotchi/web/public/data.js +5 -1
- data/lib/samagotchi/web/public/format.js +5 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/sessions_list.js +2 -3
- data/lib/samagotchi/web/public/stage_frame.js +1 -1
- data/lib/samagotchi/web/public/stage_view.js +8 -2
- data/lib/samagotchi/web/public/stream_handlers.js +6 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +55 -3
- data/lib/samagotchi/web/session_hub.rb +8 -0
- metadata +3 -1
|
@@ -33,7 +33,7 @@ module Samagotchi
|
|
|
33
33
|
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
# A hosts: entry as written
|
|
36
|
+
# A hosts: entry as written in config.yml,
|
|
37
37
|
# or nil when it is disabled (enabled: false) or invalid. An invalid
|
|
38
38
|
# one warns once, naming the entry, the way each reader of a setting
|
|
39
39
|
# does for a bad value (that value is then unset).
|
data/lib/samagotchi/host_keys.rb
CHANGED
|
@@ -131,8 +131,6 @@ module Samagotchi
|
|
|
131
131
|
# The command that stores the key for +name+ (prompting for it).
|
|
132
132
|
def store_command(name) = "security add-generic-password -s #{SERVICE} -a #{name} -w"
|
|
133
133
|
|
|
134
|
-
def clear_cache! = @mutex.synchronize { @cache.clear }
|
|
135
|
-
|
|
136
134
|
# Names only: the cache holds keys.
|
|
137
135
|
def inspect = "#<#{name} cached=#{@mutex.synchronize { @cache.keys }.inspect}>"
|
|
138
136
|
|
data/lib/samagotchi/paths.rb
CHANGED
|
@@ -28,5 +28,18 @@ module Samagotchi
|
|
|
28
28
|
xdg = env.fetch("XDG_CONFIG_HOME", "").to_s.strip
|
|
29
29
|
xdg.empty? ? File.expand_path("~/.config") : xdg
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
# +path+ as chi shows it: ~ for the home folder (~/a for one in it),
|
|
33
|
+
# else as it is; as it is too when there's no home (HOME unset).
|
|
34
|
+
# @return [String]
|
|
35
|
+
def tilde(path)
|
|
36
|
+
path = path.to_s
|
|
37
|
+
home = Dir.home
|
|
38
|
+
return "~" if path == home
|
|
39
|
+
|
|
40
|
+
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
path
|
|
43
|
+
end
|
|
31
44
|
end
|
|
32
45
|
end
|
|
@@ -279,13 +279,19 @@ module Samagotchi
|
|
|
279
279
|
|
|
280
280
|
# A named toolkit sessions can be put on (/toolkit NAME, chi --toolkit,
|
|
281
281
|
# toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
|
|
282
|
-
# a bundle has) with +add+ declared and +remove+ left out
|
|
283
|
-
#
|
|
284
|
-
#
|
|
282
|
+
# a bundle has) with +add+ declared and +remove+ left out, and
|
|
283
|
+
# +prompt+: text the sessions on it get in their system prompt (nil:
|
|
284
|
+
# its base's, "" none; at most Toolkit::PROMPT_MAX characters). A
|
|
285
|
+
# name chi (full, lean, child) or another bundle has is a load error,
|
|
286
|
+
# and so is a prompt that isn't text or is too long; a base that
|
|
287
|
+
# doesn't resolve leaves the sessions on it with every tool
|
|
285
288
|
# (Toolkit#resolve).
|
|
286
|
-
def toolkit(name, base:, add: [], remove: [])
|
|
289
|
+
def toolkit(name, base:, add: [], remove: [], prompt: nil)
|
|
287
290
|
name = name.to_s
|
|
288
291
|
raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
|
|
292
|
+
|
|
293
|
+
problem = Toolkit.prompt_error(prompt)
|
|
294
|
+
raise ArgumentError, "toolkit #{name}: #{problem}" if problem
|
|
289
295
|
raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
|
|
290
296
|
if Toolkit::BUILTIN_KITS.key?(name)
|
|
291
297
|
raise ArgumentError, "toolkit #{name} is chi's own"
|
|
@@ -294,7 +300,7 @@ module Samagotchi
|
|
|
294
300
|
end
|
|
295
301
|
raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
|
|
296
302
|
|
|
297
|
-
@toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, source: @bundle)
|
|
303
|
+
@toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt, source: @bundle)
|
|
298
304
|
nil
|
|
299
305
|
end
|
|
300
306
|
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "tempfile"
|
|
7
7
|
require "time"
|
|
8
|
+
require_relative "../paths"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Plugin
|
|
@@ -88,11 +89,10 @@ module Samagotchi
|
|
|
88
89
|
"it; install-skill: install #{peer::NAME}'s chi-peer skill", anytime: true do |args, ctx|
|
|
89
90
|
run_command(args.to_s.strip, ctx)
|
|
90
91
|
end
|
|
91
|
-
|
|
92
|
-
chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate" if chi.respond_to?(:annotation)
|
|
92
|
+
chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate"
|
|
93
93
|
# The outbox source is the peer's answers: its own wake budget and
|
|
94
|
-
# line (context.peer_turns).
|
|
95
|
-
chi.peer_source peer::OUTBOX, label: peer::NAME
|
|
94
|
+
# line (context.peer_turns).
|
|
95
|
+
chi.peer_source peer::OUTBOX, label: peer::NAME
|
|
96
96
|
chi.on(:before_turn) do |event|
|
|
97
97
|
@lock.synchronize { @before_turn = Array(event[:messages]) }
|
|
98
98
|
end
|
|
@@ -362,10 +362,7 @@ module Samagotchi
|
|
|
362
362
|
"(diff isn't there to show how)"
|
|
363
363
|
end
|
|
364
364
|
|
|
365
|
-
def tilde(path)
|
|
366
|
-
home = Dir.home
|
|
367
|
-
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
368
|
-
end
|
|
365
|
+
def tilde(path) = Paths.tilde(path)
|
|
369
366
|
|
|
370
367
|
def follow_up(kind)
|
|
371
368
|
case kind
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "memory_paths"
|
|
4
|
+
require_relative "paths"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# The files whose text goes into the system prompt as they are
|
|
@@ -76,10 +77,7 @@ module Samagotchi
|
|
|
76
77
|
rel = top && relative_dir(path, top)
|
|
77
78
|
return rel if rel && rel != "."
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
81
|
-
rescue ArgumentError
|
|
82
|
-
path
|
|
80
|
+
Paths.tilde(path)
|
|
83
81
|
end
|
|
84
82
|
|
|
85
83
|
# A chi --append-system-prompt value: [absolute path, nil], or
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -189,7 +189,8 @@ module Samagotchi
|
|
|
189
189
|
# which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
|
|
190
190
|
# again from config, so a respawned worker declares the same tools; a
|
|
191
191
|
# continue and a fork copy it (SessionSetup.of), a delegate child gets
|
|
192
|
-
# toolkit.child
|
|
192
|
+
# the one its delegate call named, else toolkit.child
|
|
193
|
+
# (SessionManager.spawn_session).
|
|
193
194
|
attr_reader :toolkit
|
|
194
195
|
|
|
195
196
|
# @param value [Toolkit, Hash, String, nil] the session file's form too
|
|
@@ -140,9 +140,10 @@ module Samagotchi
|
|
|
140
140
|
# @param title [String, nil] what the lists show before the first turn
|
|
141
141
|
# (the prompt's preview by default, else the seed's first user message)
|
|
142
142
|
# @param setup [SessionSetup] the session's own settings (chi
|
|
143
|
-
# --llm-context; a fork and a continue copy their session's
|
|
144
|
-
#
|
|
145
|
-
#
|
|
143
|
+
# --llm-context; a fork and a continue copy their session's; a delegate
|
|
144
|
+
# child the toolkit its delegate call named); without a toolkit the
|
|
145
|
+
# session is stored on config's toolkit.default, a delegate child on
|
|
146
|
+
# toolkit.child (Toolkit.configured)
|
|
146
147
|
# @param continues [String, nil] the session this one continues (a
|
|
147
148
|
# session field; SessionContinue)
|
|
148
149
|
# @param note [String, nil] a context note the session starts with,
|
|
@@ -179,7 +180,7 @@ module Samagotchi
|
|
|
179
180
|
setup: setup,
|
|
180
181
|
continues: continues
|
|
181
182
|
)
|
|
182
|
-
session.toolkit = Toolkit.configured(delegate: delegate) if
|
|
183
|
+
session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
|
|
183
184
|
session.model_warning = model_warning
|
|
184
185
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
185
186
|
# the prompts), so the session starts idle.
|
|
@@ -209,8 +210,7 @@ module Samagotchi
|
|
|
209
210
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
210
211
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
211
212
|
# (Config.cli_env) and the absolute log file. It unsets
|
|
212
|
-
# SAMAGOTCHI_GUARDRAILS_
|
|
213
|
-
# SAMAGOTCHI_HOSTS_JSON (HOSTS_JSON_ENV).
|
|
213
|
+
# SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
|
|
214
214
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
215
215
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
216
216
|
private_class_method def self.spawn_options(session)
|
|
@@ -227,12 +227,6 @@ module Samagotchi
|
|
|
227
227
|
Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
|
|
228
228
|
end
|
|
229
229
|
child_env = {}
|
|
230
|
-
# The worker reads config.yml's hosts: as it starts, like every other
|
|
231
|
-
# setting: a host added or changed after this process (a parent
|
|
232
|
-
# worker, `chi web`) started reaches it. Unset, not just left out: a
|
|
233
|
-
# worker an older chi started still has one, and its delegates would
|
|
234
|
-
# inherit its start-time hosts.
|
|
235
|
-
child_env[HOSTS_JSON_ENV] = nil
|
|
236
230
|
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
237
231
|
# other flags this chi was started with.
|
|
238
232
|
child_env.merge!(Config.cli_env)
|
|
@@ -716,10 +710,6 @@ module Samagotchi
|
|
|
716
710
|
wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
|
|
717
711
|
end
|
|
718
712
|
|
|
719
|
-
# ConfigFile.hosts_config's override of config.yml's hosts:, which no
|
|
720
|
-
# spawned worker inherits (spawn_options).
|
|
721
|
-
HOSTS_JSON_ENV = "SAMAGOTCHI_HOSTS_JSON"
|
|
722
|
-
|
|
723
713
|
STOP_CANCEL_WAIT = 3.0
|
|
724
714
|
# The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
|
|
725
715
|
# not the user's Stop, which only ends the turn ("user").
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context_status"
|
|
4
|
+
require_relative "effective_model"
|
|
5
|
+
require_relative "sampling_settings"
|
|
6
|
+
require_relative "session_setting"
|
|
7
|
+
require_relative "steer_cut"
|
|
8
|
+
require_relative "steer_cut_after"
|
|
9
|
+
require_relative "thinking"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
# The session's settings on its effective model, as the Engine reads them:
|
|
13
|
+
# the session's own thinking, reasoning_history and llm_context values
|
|
14
|
+
# (SessionSetting) over the effective model's (EffectiveModel), the
|
|
15
|
+
# summaries /model and /stats show, and what the next turn resolves.
|
|
16
|
+
# Every read resolves the model again (EffectiveModel.of, never kept); a
|
|
17
|
+
# reader with a fallback gives it when the model doesn't resolve
|
|
18
|
+
# (#model_setting). The Engine forwards its public readers here.
|
|
19
|
+
class SessionModelSettings
|
|
20
|
+
# @param registry [HostRegistry] the Engine's (never replaced)
|
|
21
|
+
# @param choice [#call] → the ModelChoice turns run on now (/model moves it)
|
|
22
|
+
# @param session [#call] → the Engine's Session, nil before one
|
|
23
|
+
def initialize(registry:, choice:, session:)
|
|
24
|
+
@registry = registry
|
|
25
|
+
@choice = choice
|
|
26
|
+
@session = session
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The effective model now (EffectiveModel.of): resolved on each call,
|
|
30
|
+
# never kept (config.yml, a model listing and another process's saved
|
|
31
|
+
# lists all move a resolve). Raises as HostRegistry#resolve does.
|
|
32
|
+
# @return [EffectiveModel]
|
|
33
|
+
def effective_model(choice = @choice.call) = EffectiveModel.of(@registry, choice)
|
|
34
|
+
|
|
35
|
+
# The block's answer for the effective model, or +fallback+ when the
|
|
36
|
+
# model doesn't resolve: the readers whose rescue always covered the
|
|
37
|
+
# resolve too. Each setting's own failure is the EffectiveModel's.
|
|
38
|
+
def model_setting(fallback)
|
|
39
|
+
# One read: the log line names the model that failed, even if a
|
|
40
|
+
# /model switched it meanwhile.
|
|
41
|
+
choice = @choice.call
|
|
42
|
+
model = begin
|
|
43
|
+
effective_model(choice)
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
return EffectiveModel::Unresolved.fallback(:effective_model_unresolved, choice.name, e, fallback)
|
|
46
|
+
end
|
|
47
|
+
yield model
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The status line's ctx from the saved context, for a worker that has run
|
|
51
|
+
# no turn yet (it woke after an idle exit): nil when either count is
|
|
52
|
+
# unknown or context.status is off. Counted as the turns count it:
|
|
53
|
+
# against the effective model's llm_context budget when it has one.
|
|
54
|
+
def saved_context_status(context, model: :current)
|
|
55
|
+
return nil unless context
|
|
56
|
+
|
|
57
|
+
llm_context = model == :current ? turn_llm_context : model&.llm_context_explained(llm_context_session_args)&.resolved
|
|
58
|
+
ContextStatus.new(llm_context: llm_context)
|
|
59
|
+
.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# #turn_llm_context with where each value came from
|
|
63
|
+
# (LLMContextStrategy.explain), nil when it can't be resolved.
|
|
64
|
+
# @return [LLMContextStrategy::Explained, nil]
|
|
65
|
+
def llm_context_explained
|
|
66
|
+
model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# #llm_context_explained's summary (Explained#summary), nil when it
|
|
70
|
+
# can't be resolved: /stats, the snapshot, the web's info bar.
|
|
71
|
+
# @return [Hash, nil]
|
|
72
|
+
def llm_context_summary
|
|
73
|
+
llm_context_explained&.summary(llm_context_override)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The session's own llm_context values, nil when it has none.
|
|
77
|
+
# @return [LLMContextOverride, nil]
|
|
78
|
+
def llm_context_override = SessionSetting::LLM_CONTEXT.own(@session.call)
|
|
79
|
+
|
|
80
|
+
# The effective model is on a chat host (api: openai or anthropic), whose loop uses
|
|
81
|
+
# no prompt profile.
|
|
82
|
+
def chat_model? = effective_model.chat?
|
|
83
|
+
|
|
84
|
+
# "temperature=0.6 (hosts.work)" for /model: the effective model's
|
|
85
|
+
# configured sampling, nil when none.
|
|
86
|
+
def sampling_summary = model_setting(nil, &:sampling_summary)
|
|
87
|
+
|
|
88
|
+
# "off (models: qwen)", "low (session)" for /model: the effective
|
|
89
|
+
# model's thinking level and where it came from, nil when none is set.
|
|
90
|
+
def thinking_summary
|
|
91
|
+
explained = thinking_explained
|
|
92
|
+
explained&.source ? explained.label : nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The seconds a cut waits for the effective model to stream only
|
|
96
|
+
# thinking (SteerCutAfter.resolve), resolved on every call, as
|
|
97
|
+
# turn_sampling; on any error, the global and its name.
|
|
98
|
+
# @return [Array(Integer, String|nil)]
|
|
99
|
+
def steer_cut_after_explained
|
|
100
|
+
model_setting([SteerCut.cut_after, "steer.cut_after"], &:steer_cut_after_explained)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
|
|
104
|
+
# reads, per host and model.
|
|
105
|
+
def steer_cut_after = steer_cut_after_explained.first
|
|
106
|
+
|
|
107
|
+
# "after 60 s (models: qwen)", "never (hosts.splash)" for /model: the
|
|
108
|
+
# effective model's cut wait and where it came from, nil for the
|
|
109
|
+
# default.
|
|
110
|
+
def steer_cut_summary
|
|
111
|
+
seconds, source = steer_cut_after_explained
|
|
112
|
+
source ? SteerCutAfter.label(seconds, source) : nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The effective model's thinking level with where it came from and the
|
|
116
|
+
# session's own (SettingExplained), as the next turn resolves it;
|
|
117
|
+
# nil when it can't be resolved.
|
|
118
|
+
# @return [SettingExplained, nil]
|
|
119
|
+
# +own+: the session's own level to resolve with (the current one by
|
|
120
|
+
# default; /thinking asks what a change would resolve to).
|
|
121
|
+
def thinking_explained(own: thinking_override)
|
|
122
|
+
model_setting(nil) { |model| model.thinking_explained(own: own) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# "drop (models: qwen)" for /model: the effective chat model's
|
|
126
|
+
# reasoning_history level and where it came from; nil when it is the
|
|
127
|
+
# default, the model runs on the native loop (which ignores it) or it
|
|
128
|
+
# can't be resolved.
|
|
129
|
+
def reasoning_history_summary
|
|
130
|
+
model_setting(nil) { |model| model.reasoning_history_summary(own: reasoning_history_override) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The effective chat model's reasoning_history level with where it came
|
|
134
|
+
# from and the session's own (SettingExplained), as the next turn
|
|
135
|
+
# resolves it; nil on a native host (the native loop ignores it; native:
|
|
136
|
+
# true resolves it there too, for /reasoning-history) or when it can't
|
|
137
|
+
# be resolved.
|
|
138
|
+
# @return [SettingExplained, nil]
|
|
139
|
+
# +own+: the session's own level to resolve with (the current one by
|
|
140
|
+
# default; a command asks what a change would resolve to).
|
|
141
|
+
def reasoning_history_explained(native: false, own: reasoning_history_override)
|
|
142
|
+
model_setting(nil) { |model| model.reasoning_history_explained(own: own, native: native) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# What a reasoning_history change from level +from+ to level +to+ costs
|
|
146
|
+
# the effective model's prompt cache, given the session's conversation
|
|
147
|
+
# (ReasoningHistory.cache_cost): :history or nil. A host ChatLoop
|
|
148
|
+
# marked as refusing it may still get :history (one needless ask).
|
|
149
|
+
# @return [Symbol, nil]
|
|
150
|
+
def reasoning_history_cache_cost(from, to)
|
|
151
|
+
model_setting(nil) { |model| model.reasoning_history_cache_cost(from, to, conversation: @session.call&.messages) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The session's own reasoning_history level, nil when it has none.
|
|
155
|
+
# @return [Symbol, nil]
|
|
156
|
+
def reasoning_history_override = SessionSetting::REASONING_HISTORY.own(@session.call)
|
|
157
|
+
|
|
158
|
+
# Set the session's own reasoning_history level (nil unsets it); the
|
|
159
|
+
# next turn's start resolves it. The caller saves the session.
|
|
160
|
+
# @raise [ArgumentError] with no session yet
|
|
161
|
+
def reasoning_history_override=(level)
|
|
162
|
+
session = @session.call
|
|
163
|
+
raise ArgumentError, "no session yet" unless session
|
|
164
|
+
|
|
165
|
+
SessionSetting::REASONING_HISTORY.set(session, level)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The session's own thinking level, nil when it has none.
|
|
169
|
+
# @return [Symbol, nil]
|
|
170
|
+
def thinking_override = SessionSetting::THINKING.own(@session.call)
|
|
171
|
+
|
|
172
|
+
# Set the session's own thinking level (nil unsets it); the next turn's
|
|
173
|
+
# start resolves it, never a running one (its TurnSettings are fixed).
|
|
174
|
+
# The caller saves the session.
|
|
175
|
+
# @raise [ArgumentError] with no session yet
|
|
176
|
+
def thinking_override=(level)
|
|
177
|
+
session = @session.call
|
|
178
|
+
raise ArgumentError, "no session yet" unless session
|
|
179
|
+
|
|
180
|
+
SessionSetting::THINKING.set(session, level)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The host whose hosts.<name>.models.<id>.served names +served+ for the
|
|
184
|
+
# current model (a gateway's round-robin targets): not a mismatch to
|
|
185
|
+
# warn about. nil when none does.
|
|
186
|
+
# @return [String, nil]
|
|
187
|
+
def served_expected_by(served)
|
|
188
|
+
model_setting(nil) { |model| model.served_expected_by(served) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def served_expected?(served) = !served_expected_by(served).nil?
|
|
192
|
+
|
|
193
|
+
# The effective model's request parameters (hosts: and models:
|
|
194
|
+
# sampling), read per turn: /model can change the model between turns.
|
|
195
|
+
def turn_sampling = model_setting(SamplingSettings::EMPTY, &:sampling)
|
|
196
|
+
|
|
197
|
+
# The effective model's thinking level (Thinking.resolve), read each turn.
|
|
198
|
+
def turn_thinking = model_setting(Thinking::DEFAULT) { |model| thinking_level(model) }
|
|
199
|
+
|
|
200
|
+
# +model+'s ReasoningHistory level, the session's own first; the
|
|
201
|
+
# default when it can't be resolved.
|
|
202
|
+
def reasoning_history_level(model) = model.reasoning_history(own: reasoning_history_override)
|
|
203
|
+
|
|
204
|
+
# +model+'s thinking level (Thinking.resolve), the session's own first.
|
|
205
|
+
def thinking_level(model) = model.thinking(own: thinking_override)
|
|
206
|
+
|
|
207
|
+
# The effective model's models:/hosts: window_tokens, read per turn
|
|
208
|
+
# (after the hooks that may switch the model).
|
|
209
|
+
def turn_window_setting = model_setting(nil, &:window_setting)
|
|
210
|
+
|
|
211
|
+
# The effective model's price on its host (hosts.<name>.models.<id>.price),
|
|
212
|
+
# by the requested id, never the served one; read per turn as the window is.
|
|
213
|
+
def turn_price = model_setting(nil, &:price)
|
|
214
|
+
|
|
215
|
+
# The LLM context strategy the next turn runs under: the session's own
|
|
216
|
+
# (Session#llm_context), else the effective model's (models:/hosts:
|
|
217
|
+
# llm_context_strategy), else llm_context.strategy; read per turn as
|
|
218
|
+
# the window is.
|
|
219
|
+
def turn_llm_context
|
|
220
|
+
llm_context_explained&.resolved
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The session's own llm_context values as LLMContextStrategy's
|
|
224
|
+
# arguments ({} without a session or values).
|
|
225
|
+
def llm_context_session_args = @session.call&.llm_context&.resolve_args || {}
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -19,7 +19,8 @@ module Samagotchi
|
|
|
19
19
|
#
|
|
20
20
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
21
21
|
# and file key, and resolves it on its own. A delegate child starts
|
|
22
|
-
# without one; a continue and a
|
|
22
|
+
# without one (but the toolkit its delegate call named); a continue and a
|
|
23
|
+
# fork copy their session's (.of).
|
|
23
24
|
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit) do
|
|
24
25
|
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
|
|
25
26
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
@@ -37,7 +37,8 @@ module Samagotchi
|
|
|
37
37
|
archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
|
|
38
38
|
delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
|
|
39
39
|
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
40
|
-
clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
40
|
+
clean [--dry-run] [--days N] [--all] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
41
|
+
--all: every session, not only test and scratch ones (by the age and count limits, as prune)
|
|
41
42
|
stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
|
|
42
43
|
a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
|
|
43
44
|
Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
|
|
@@ -107,6 +108,8 @@ module Samagotchi
|
|
|
107
108
|
return parsed if parsed.is_a?(Integer)
|
|
108
109
|
|
|
109
110
|
@opts = integers(parsed.options)
|
|
111
|
+
return usage_error(@opts) if @opts.is_a?(String)
|
|
112
|
+
|
|
110
113
|
case @sub
|
|
111
114
|
when "list" then list
|
|
112
115
|
when "stop" then stop(parsed.args)
|
|
@@ -121,9 +124,15 @@ module Samagotchi
|
|
|
121
124
|
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
|
|
122
125
|
def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE, "stats" => STATS_USAGE }.fetch(@sub, USAGE)
|
|
123
126
|
|
|
124
|
-
#
|
|
127
|
+
# The options with --days, --keep and --limit as integers, or the usage
|
|
128
|
+
# error's message for one that isn't a whole number (--days=abc must not
|
|
129
|
+
# turn the age limit off).
|
|
130
|
+
# @return [Hash, String]
|
|
125
131
|
def integers(options)
|
|
126
|
-
options.
|
|
132
|
+
bad = options.find { |key, value| INTEGER_OPTIONS.include?(key) && !value.to_s.match?(/\A-?\d+\z/) }
|
|
133
|
+
return "--#{bad[0]} takes a whole number, got #{bad[1].to_s.inspect}" if bad
|
|
134
|
+
|
|
135
|
+
options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? Integer(value, 10) : value] }
|
|
127
136
|
end
|
|
128
137
|
|
|
129
138
|
# The flags a list row carries: a chi scratch session, a test run, an
|
|
@@ -4,6 +4,7 @@ require_relative "config"
|
|
|
4
4
|
require_relative "log"
|
|
5
5
|
require_relative "log_path"
|
|
6
6
|
require_relative "memory_paths"
|
|
7
|
+
require_relative "paths"
|
|
7
8
|
require_relative "tool_declarations"
|
|
8
9
|
require_relative "tools/memory"
|
|
9
10
|
require_relative "muted_memories"
|
|
@@ -305,10 +306,11 @@ module Samagotchi
|
|
|
305
306
|
# Ordered for the servers' prompt caches (they reuse only an exact token
|
|
306
307
|
# prefix): what every session shares first, what changes least before
|
|
307
308
|
# what changes more, and the per-session lines last. The global parts
|
|
308
|
-
# (identity, preloads) come before the project's AGENTS.md, the
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
#
|
|
309
|
+
# (identity, preloads) come before the project's AGENTS.md, the
|
|
310
|
+
# toolkit's prompt text after the appended files, the memory indexes (a
|
|
311
|
+
# memory write changes them) after all of them; the model, the location
|
|
312
|
+
# and the session close the prompt (Gemma's tool declarations still
|
|
313
|
+
# follow them).
|
|
312
314
|
def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
|
|
313
315
|
thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
|
|
314
316
|
indexes = { project: read_memory_index("project"), system: read_memory_index("system") }
|
|
@@ -318,7 +320,7 @@ module Samagotchi
|
|
|
318
320
|
"System memories:\n#{indexes[:system]}"
|
|
319
321
|
].join("\n\n")
|
|
320
322
|
stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
|
|
321
|
-
project_specific_description, appended_section, memory_sections]
|
|
323
|
+
project_specific_description, appended_section, kit_prompt_section, memory_sections]
|
|
322
324
|
stable_text = stable.compact.join("\n")
|
|
323
325
|
volatile = [current_model, project_location, current_session].compact
|
|
324
326
|
return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
|
|
@@ -359,6 +361,16 @@ module Samagotchi
|
|
|
359
361
|
"(/toolkit add <name>)."
|
|
360
362
|
end
|
|
361
363
|
|
|
364
|
+
# The session's toolkit's prompt text (a kit's prompt:, Toolkit::Kit),
|
|
365
|
+
# verbatim, after the appended files and before the memory indexes:
|
|
366
|
+
# sessions on that kit share it, and a memory write doesn't move it.
|
|
367
|
+
# nil on a kit without one and on full: the prompt is what it was
|
|
368
|
+
# before kit prompts.
|
|
369
|
+
def kit_prompt_section
|
|
370
|
+
registry = @tools_lookup.call
|
|
371
|
+
registry.respond_to?(:toolkit) ? registry.toolkit&.prompt : nil
|
|
372
|
+
end
|
|
373
|
+
|
|
362
374
|
def with_tail_declarations(prompt, chat:, layers:)
|
|
363
375
|
chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
|
|
364
376
|
end
|
|
@@ -600,19 +612,12 @@ module Samagotchi
|
|
|
600
612
|
home = Dir.home
|
|
601
613
|
lines << "Home directory: #{home} (write it as ~ or $HOME in commands and paths)" unless home.to_s.empty?
|
|
602
614
|
lines << "Project memories folder (your notes about the project, not its files; never work or run commands there):"
|
|
603
|
-
lines <<
|
|
615
|
+
lines << Paths.tilde(Tools::MemoryRead.memories_dir("project"))
|
|
604
616
|
lines.join("\n")
|
|
605
617
|
rescue StandardError
|
|
606
618
|
nil
|
|
607
619
|
end
|
|
608
620
|
|
|
609
|
-
def home_relative(path)
|
|
610
|
-
home = Dir.home
|
|
611
|
-
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
612
|
-
rescue ArgumentError
|
|
613
|
-
path
|
|
614
|
-
end
|
|
615
|
-
|
|
616
621
|
# The model this session runs on. A model guesses its name from
|
|
617
622
|
# training (a fine-tune often knows only its base model's), so the line
|
|
618
623
|
# says to answer from here. It depends only on the effective model, and
|
|
@@ -1275,7 +1275,10 @@ module Samagotchi
|
|
|
1275
1275
|
elsif %w[model assistant].include?(m[:role].to_s)
|
|
1276
1276
|
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
1277
1277
|
Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
|
|
1278
|
-
|
|
1278
|
+
record = join_record(records, part)
|
|
1279
|
+
duration = record&.dig("duration_ms")
|
|
1280
|
+
@screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
|
|
1281
|
+
saved_status: record&.dig("status")))
|
|
1279
1282
|
end
|
|
1280
1283
|
end
|
|
1281
1284
|
end
|
|
@@ -1307,28 +1310,36 @@ module Samagotchi
|
|
|
1307
1310
|
nil
|
|
1308
1311
|
end
|
|
1309
1312
|
|
|
1310
|
-
# The
|
|
1311
|
-
# order, consumed with the ones it
|
|
1312
|
-
#
|
|
1313
|
-
#
|
|
1314
|
-
#
|
|
1315
|
-
|
|
1313
|
+
# The saved record of +part+'s call (its duration and status): the
|
|
1314
|
+
# next record of its tool, in call order, consumed with the ones it
|
|
1315
|
+
# skipped. By tool and order, not by iteration: an empty retry spends
|
|
1316
|
+
# an iteration that left no message (as the web's timing.js
|
|
1317
|
+
# takeRecord). nil for a call that left none: it never finished, or a
|
|
1318
|
+
# worker before turn ids saved it.
|
|
1319
|
+
def join_record(records, part)
|
|
1316
1320
|
return nil if part[:output].nil?
|
|
1317
1321
|
|
|
1318
1322
|
at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
|
|
1319
1323
|
return nil unless at
|
|
1320
1324
|
|
|
1321
|
-
|
|
1322
|
-
records.shift(at + 1)
|
|
1323
|
-
duration.is_a?(Numeric) ? duration : nil
|
|
1325
|
+
records.shift(at + 1).last
|
|
1324
1326
|
end
|
|
1325
1327
|
|
|
1326
1328
|
# A saved call's row; "no result" for a call the turn never answered
|
|
1327
|
-
# (it ended first).
|
|
1328
|
-
|
|
1329
|
+
# (it ended first). Its status is the saved record's, as live and the
|
|
1330
|
+
# web's reload (a guardrail's "blocked" is no error), else read from
|
|
1331
|
+
# its result.
|
|
1332
|
+
def join_tool_line(part, duration_ms: nil, saved_status: nil)
|
|
1329
1333
|
output = part[:output]
|
|
1330
1334
|
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
1331
|
-
status = output.nil?
|
|
1335
|
+
status = if output.nil?
|
|
1336
|
+
"no result"
|
|
1337
|
+
elsif !saved_status.to_s.empty?
|
|
1338
|
+
saved_status.to_s
|
|
1339
|
+
else
|
|
1340
|
+
ToolActivity.tool_activity_status(ToolActivity.saved_result_text(output), part[:tool],
|
|
1341
|
+
command: command)
|
|
1342
|
+
end
|
|
1332
1343
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1333
1344
|
params: part[:params], status: status,
|
|
1334
1345
|
description: ToolView.description_title(part[:view] || {}) }
|
|
@@ -1413,7 +1424,8 @@ module Samagotchi
|
|
|
1413
1424
|
# hook's notice, a retry's line), as they were drawn live.
|
|
1414
1425
|
# Questions are left out: the pending one is asked after the
|
|
1415
1426
|
# replay, answered ones showed no line.
|
|
1416
|
-
when :turn_started
|
|
1427
|
+
when :turn_started then @renderer.call(without_named_images(event, turn[:prompt]))
|
|
1428
|
+
when :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
|
|
1417
1429
|
@renderer.call(event)
|
|
1418
1430
|
end
|
|
1419
1431
|
end
|
|
@@ -1454,7 +1466,16 @@ module Samagotchi
|
|
|
1454
1466
|
elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
|
|
1455
1467
|
@screen.commit(prompt_line(origin[:client_id], event[:prompt]))
|
|
1456
1468
|
end
|
|
1457
|
-
|
|
1469
|
+
# Another client's prompt line is up (here or at its enqueue); an
|
|
1470
|
+
# own send has none, so its image lines are its echo.
|
|
1471
|
+
@renderer.call(own?(origin[:client_id]) ? event : without_named_images(event, event[:prompt]))
|
|
1472
|
+
end
|
|
1473
|
+
|
|
1474
|
+
# The turn's start without its image lines when the prompt line shown
|
|
1475
|
+
# for it is the web's "[image: name]" text of a turn of images alone:
|
|
1476
|
+
# that line names them already.
|
|
1477
|
+
def without_named_images(event, shown_prompt)
|
|
1478
|
+
ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
|
|
1458
1479
|
end
|
|
1459
1480
|
|
|
1460
1481
|
def complete_turn(event)
|
|
@@ -602,6 +602,10 @@ module Samagotchi
|
|
|
602
602
|
# The files whose text is in the prompt (SystemPrompt#prompt_files).
|
|
603
603
|
files = prompt_files_text(snapshot[:prompt_files])
|
|
604
604
|
lines << "prompt files: #{files}" if files
|
|
605
|
+
# The toolkit's prompt text (Engine#stats_snapshot's kit_prompt).
|
|
606
|
+
if (kit = snapshot[:kit_prompt])
|
|
607
|
+
lines << "kit prompt: #{kit[:kit]} (~#{kit[:tokens]} tokens)"
|
|
608
|
+
end
|
|
605
609
|
llm_context = llm_context_stats_text(snapshot[:llm_context])
|
|
606
610
|
lines << "llm context: #{llm_context}" if llm_context
|
|
607
611
|
thinking = setting_stats_text(snapshot[:thinking])
|