samagotchi 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +5 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +10 -13
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundle_command.rb +9 -13
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +23 -0
- data/lib/samagotchi/engine.rb +63 -18
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +18 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/builder.rb +23 -21
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installer.rb +4 -4
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +40 -46
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +68 -11
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +44 -11
- data/lib/samagotchi/session_metrics.rb +15 -20
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
- data/lib/samagotchi/terminal_ui.rb +21 -4
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +56 -13
- data/lib/samagotchi/web/public/app.js +131 -20
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +8 -5
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/thinking_chip.js +179 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- metadata +13 -1
|
@@ -61,7 +61,7 @@ module Samagotchi
|
|
|
61
61
|
# idle again ends the wait even if it was never seen running, and a
|
|
62
62
|
# question already pending then is not the answer's. A nil
|
|
63
63
|
# messages: skips the count. last_turn: (the session's
|
|
64
|
-
# last_turn
|
|
64
|
+
# last_turn.ended_at then, maybe nil) ends the wait on any turn
|
|
65
65
|
# that ended since, idle again, however fast it ran (a failure after
|
|
66
66
|
# a failure replaces the note and leaves the count as it was), and
|
|
67
67
|
# with it the count isn't used: a note between turns grows it too.
|
|
@@ -141,8 +141,7 @@ module Samagotchi
|
|
|
141
141
|
# ended, so a turn that ends before the first look still ends the wait.
|
|
142
142
|
# @return [Hash]
|
|
143
143
|
def baseline_of(session, question_id: session.pending_question&.dig(:id))
|
|
144
|
-
|
|
145
|
-
{ messages: session.messages.size, question_id: question_id, last_turn: last }
|
|
144
|
+
{ messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
|
|
146
145
|
end
|
|
147
146
|
|
|
148
147
|
# An idle session whose turn ended after the baseline was taken: its
|
|
@@ -155,7 +154,7 @@ module Samagotchi
|
|
|
155
154
|
return false unless baseline
|
|
156
155
|
|
|
157
156
|
if baseline.key?(:last_turn)
|
|
158
|
-
ended = session.last_turn
|
|
157
|
+
ended = session.last_turn&.ended_at
|
|
159
158
|
return !ended.nil? && ended != baseline[:last_turn]
|
|
160
159
|
end
|
|
161
160
|
!!(baseline[:messages] && session.messages.size > baseline[:messages])
|
|
@@ -164,15 +163,14 @@ module Samagotchi
|
|
|
164
163
|
# @return [Result] :no_reply with the outcome the session's last_turn
|
|
165
164
|
# records, when it is the turn waited for
|
|
166
165
|
def no_reply(session, baseline)
|
|
167
|
-
last = session.last_turn
|
|
168
|
-
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last
|
|
169
|
-
outcome = fresh ? last
|
|
170
|
-
if fresh && last
|
|
171
|
-
return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"], session: session)
|
|
172
|
-
end
|
|
166
|
+
last = session.last_turn || LastTurn.new
|
|
167
|
+
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn])
|
|
168
|
+
outcome = fresh ? last.outcome : nil
|
|
169
|
+
return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
|
|
173
170
|
|
|
174
171
|
text = outcome == "failed" ? TurnNote.failure_summary(session.messages) : nil
|
|
175
|
-
|
|
172
|
+
# Every stop fact the turn has: Result takes them by name.
|
|
173
|
+
why = fresh ? last.stop_facts : {}
|
|
176
174
|
Result.new(status: :no_reply, outcome: outcome, text: text, session: session, **why)
|
|
177
175
|
end
|
|
178
176
|
|
|
@@ -188,12 +186,12 @@ module Samagotchi
|
|
|
188
186
|
def settled(session, id, state_dir:)
|
|
189
187
|
return session unless session.status == Session::STATUS_RUNNING
|
|
190
188
|
|
|
191
|
-
before = session.last_turn
|
|
189
|
+
before = session.last_turn&.ended_at
|
|
192
190
|
deadline = monotonic + SETTLE_SECONDS
|
|
193
191
|
loop do
|
|
194
192
|
sleep(0.02)
|
|
195
193
|
fresh = Session.load(id, state_dir: state_dir)
|
|
196
|
-
ended = fresh.last_turn
|
|
194
|
+
ended = fresh.last_turn&.ended_at
|
|
197
195
|
return fresh if fresh.status != Session::STATUS_RUNNING || ended != before || monotonic > deadline
|
|
198
196
|
end
|
|
199
197
|
rescue ArgumentError
|
|
@@ -13,7 +13,7 @@ module Samagotchi
|
|
|
13
13
|
|
|
14
14
|
# @param target [HostRegistry::ModelTarget]
|
|
15
15
|
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
16
|
-
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
16
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
17
17
|
# @return [Hash] frozen, symbol keys; may be empty
|
|
18
18
|
def self.for(target, names:, models: nil)
|
|
19
19
|
resolve(target, names, models).first
|
|
@@ -44,11 +44,11 @@ module Samagotchi
|
|
|
44
44
|
{}
|
|
45
45
|
end
|
|
46
46
|
host = target.entry.sampling || EMPTY
|
|
47
|
-
|
|
47
|
+
model = ConfigFile.model_setting(names, :sampling, models: models)
|
|
48
48
|
sources = []
|
|
49
49
|
sources << "hosts.#{target.entry.name}" unless host.empty?
|
|
50
|
-
sources << "models: #{key}" if
|
|
51
|
-
[host.merge(model || EMPTY).freeze, sources]
|
|
50
|
+
sources << "models: #{model.key}" if model
|
|
51
|
+
[host.merge(model&.value || EMPTY).freeze, sources]
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
private_class_method :resolve
|
|
@@ -145,13 +145,15 @@ module Samagotchi
|
|
|
145
145
|
return nil unless window
|
|
146
146
|
return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
|
|
147
147
|
|
|
148
|
-
[window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).
|
|
148
|
+
[window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).key}"]
|
|
149
149
|
rescue StandardError
|
|
150
150
|
nil
|
|
151
151
|
end
|
|
152
152
|
|
|
153
153
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
154
154
|
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
155
|
+
# The session's own thinking level, beside its model (Builtins.parent_env).
|
|
156
|
+
SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
|
|
155
157
|
|
|
156
158
|
# The model of the session this chi self runs in (its execute exports
|
|
157
159
|
# SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
|
|
@@ -355,13 +357,14 @@ module Samagotchi
|
|
|
355
357
|
end
|
|
356
358
|
end
|
|
357
359
|
|
|
358
|
-
# The model's thinking level and where it came from (Thinking.resolve)
|
|
360
|
+
# The model's thinking level and where it came from (Thinking.resolve);
|
|
361
|
+
# in a session's commands its own level first (SAMAGOTCHI_SESSION_THINKING).
|
|
359
362
|
def thinking_for(model, env)
|
|
360
363
|
registry = HostRegistry.new(env: env)
|
|
361
364
|
target = registry.resolve(model)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
+
session = session_model(env) ? Thinking.session_level(env[SESSION_THINKING_ENV]) : nil
|
|
366
|
+
Thinking.explain(target, names: registry.lookup_names(model, target: target),
|
|
367
|
+
models: ConfigFile.model_settings(env: env), session: session).label
|
|
365
368
|
rescue StandardError => e
|
|
366
369
|
"(unknown: #{e.message})"
|
|
367
370
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
4
|
require_relative "delivery"
|
|
5
|
+
require_relative "terminal_ui/formatting"
|
|
5
6
|
require_relative "session"
|
|
6
7
|
require_relative "session_commands"
|
|
7
8
|
require_relative "session_inbox"
|
|
@@ -33,10 +34,12 @@ module Samagotchi
|
|
|
33
34
|
CLIENT_ID = ClientId::CLI_SEND
|
|
34
35
|
# The cap on one turn's images the Bridge checks.
|
|
35
36
|
MAX_IMAGES = ImageStore::MAX_TURN_REFS
|
|
37
|
+
# A cut's outcomes in the Bridge's ack (SteerCut#cut_for_steer).
|
|
38
|
+
CUT_OUTCOMES = %w[now waits off].freeze
|
|
36
39
|
|
|
37
40
|
USAGE = <<~TEXT
|
|
38
41
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
|
|
39
|
-
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
|
|
42
|
+
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
|
|
40
43
|
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
41
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
42
45
|
chi send --wait [--timeout S] [--format json] ID
|
|
@@ -59,16 +62,20 @@ module Samagotchi
|
|
|
59
62
|
the current one
|
|
60
63
|
--continues ID
|
|
61
64
|
(--new) start the next link of that session's chain:
|
|
62
|
-
in its folder, on its model
|
|
63
|
-
note carrying its recap; it
|
|
64
|
-
names the chain's latest link.
|
|
65
|
-
session waits idle. Refused for a
|
|
66
|
-
already (naming the next link) or
|
|
67
|
-
still open
|
|
65
|
+
in its folder, on its model, LLM context and
|
|
66
|
+
thinking level, with a note carrying its recap; it
|
|
67
|
+
is archived. last:ID names the chain's latest link.
|
|
68
|
+
Without -m the new session waits idle. Refused for a
|
|
69
|
+
session continued already (naming the next link) or
|
|
70
|
+
with delegates still open
|
|
68
71
|
--model M (--new) its model; default the configured one
|
|
69
72
|
--llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
|
|
70
73
|
(--new) its own LLM context strategy (none, stale,forget),
|
|
71
74
|
apply rule and budget (64k, off), as chi takes them
|
|
75
|
+
--thinking LEVEL
|
|
76
|
+
(--new) its own thinking level (off, low, medium,
|
|
77
|
+
high; default: none of its own), above a process
|
|
78
|
+
default such as SAMAGOTCHI_THINKING_LEVEL
|
|
72
79
|
--wait wait for the answer and print it (one session); the
|
|
73
80
|
other lines go to stderr. Exit 3: it waits for an
|
|
74
81
|
answer from you: the question, its options and the
|
|
@@ -110,6 +117,7 @@ module Samagotchi
|
|
|
110
117
|
f.value "--llm-context", key: :llm_strategy
|
|
111
118
|
f.value "--llm-context-apply", key: :llm_apply
|
|
112
119
|
f.value "--llm-context-budget", key: :llm_budget
|
|
120
|
+
f.value "--thinking"
|
|
113
121
|
f.value "--image", key: :images, repeat: true
|
|
114
122
|
f.switch "--wait"
|
|
115
123
|
f.switch "--cut"
|
|
@@ -242,7 +250,7 @@ module Samagotchi
|
|
|
242
250
|
return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
|
|
243
251
|
return new_options(options) if options[:new]
|
|
244
252
|
|
|
245
|
-
%i[dir model continues].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
253
|
+
%i[dir model continues thinking].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
246
254
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
247
255
|
return usage_error("give session ids") if options[:ids].empty?
|
|
248
256
|
|
|
@@ -259,9 +267,9 @@ module Samagotchi
|
|
|
259
267
|
|
|
260
268
|
if options[:continues]
|
|
261
269
|
taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
|
|
262
|
-
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] }
|
|
270
|
+
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])]
|
|
263
271
|
unless taken.compact.empty?
|
|
264
|
-
return usage_error("--continues takes the previous session's folder, model and
|
|
272
|
+
return usage_error("--continues takes the previous session's folder, model, LLM context and thinking level; " \
|
|
265
273
|
"leave out #{taken.compact.join(", ")}")
|
|
266
274
|
end
|
|
267
275
|
end
|
|
@@ -273,6 +281,15 @@ module Samagotchi
|
|
|
273
281
|
return usage_error(e.message)
|
|
274
282
|
end
|
|
275
283
|
|
|
284
|
+
if options[:thinking]
|
|
285
|
+
word = options[:thinking].strip.downcase
|
|
286
|
+
unless Thinking::LEVELS.map(&:to_s).include?(word)
|
|
287
|
+
return usage_error("--thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
options[:thinking] = Thinking.session_level(word)
|
|
291
|
+
end
|
|
292
|
+
|
|
276
293
|
if options[:dir]
|
|
277
294
|
options[:dir] = File.expand_path(options[:dir])
|
|
278
295
|
return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
|
|
@@ -317,7 +334,9 @@ module Samagotchi
|
|
|
317
334
|
SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
|
|
318
335
|
else
|
|
319
336
|
SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
320
|
-
model_name: options[:model],
|
|
337
|
+
model_name: options[:model],
|
|
338
|
+
setup: SessionSetup.new(llm_context: options[:llm_context],
|
|
339
|
+
thinking: options[:thinking]),
|
|
321
340
|
state_dir: @state_dir)
|
|
322
341
|
end
|
|
323
342
|
rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
|
@@ -462,26 +481,8 @@ module Samagotchi
|
|
|
462
481
|
# (a bundle's too).
|
|
463
482
|
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
464
483
|
|
|
465
|
-
#
|
|
466
|
-
|
|
467
|
-
# it on. What the UIs run: the line as the entry's match takes it — bare
|
|
468
|
-
# "exit" too (/exit's match), but not with arguments ("/exit --delete" is
|
|
469
|
-
# a UI line; "/stats please" is a prompt), as in a terminal.
|
|
470
|
-
# @return [Commands::Registry::Entry, nil]
|
|
471
|
-
def local_command(text)
|
|
472
|
-
line = text.to_s.strip
|
|
473
|
-
return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
|
|
474
|
-
|
|
475
|
-
entry = SessionCommands.builtin_registry.lookup_local(line)
|
|
476
|
-
# Only the exact line the UI runs: "/stats please" is a prompt, and
|
|
477
|
-
# "/exit --delete" is left to the worker (the TUI runs it as /exit, the
|
|
478
|
-
# worker answers its unknown-command way). Bare "exit" is /exit's line
|
|
479
|
-
# as its match takes it; the reply names the command, /exit.
|
|
480
|
-
return nil unless entry
|
|
481
|
-
return entry if line == entry.name
|
|
482
|
-
|
|
483
|
-
line.match?(/\Aexit\z/i) ? entry : nil
|
|
484
|
-
end
|
|
484
|
+
# @return [Commands::Registry::Entry, nil] SessionCommands.ui_only_entry
|
|
485
|
+
def local_command(text) = SessionCommands.ui_only_entry(text)
|
|
485
486
|
|
|
486
487
|
# A UI-only command: nothing is sent (with --new, no session is created
|
|
487
488
|
# either). /stats points at its model-free reader and names the session;
|
|
@@ -506,13 +507,7 @@ module Samagotchi
|
|
|
506
507
|
CLI::Exit::FAILED
|
|
507
508
|
end
|
|
508
509
|
|
|
509
|
-
|
|
510
|
-
# reader (naming the session when there is one).
|
|
511
|
-
def local_refusal(name, id)
|
|
512
|
-
return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
|
|
513
|
-
|
|
514
|
-
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
515
|
-
end
|
|
510
|
+
def local_refusal(name, id) = SessionCommands.ui_only_refusal(name, id, runner: "chi send")
|
|
516
511
|
|
|
517
512
|
# --wait after a message that ran as a session command: no reply comes
|
|
518
513
|
# for it. Its output shows where the session is open.
|
|
@@ -585,14 +580,13 @@ module Samagotchi
|
|
|
585
580
|
# rather than merging it into the running one.
|
|
586
581
|
return " (runs after the current turn)" unless @images.empty?
|
|
587
582
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
end
|
|
583
|
+
# The TUIs' words for a cut's outcome. No outcome: a plain message,
|
|
584
|
+
# or the input file (the Bridge went away), which the next step's
|
|
585
|
+
# drain takes.
|
|
586
|
+
outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
|
|
587
|
+
return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
|
|
588
|
+
|
|
589
|
+
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym)}"
|
|
596
590
|
end
|
|
597
591
|
|
|
598
592
|
# More keys for --format json, with --cut only (the line is unchanged
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -6,7 +6,9 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
8
|
require_relative "context_note"
|
|
9
|
+
require_relative "last_turn"
|
|
9
10
|
require_relative "llm_context_override"
|
|
11
|
+
require_relative "session_setup"
|
|
10
12
|
require_relative "prompt_note"
|
|
11
13
|
|
|
12
14
|
require_relative "paths"
|
|
@@ -86,14 +88,16 @@ module Samagotchi
|
|
|
86
88
|
# A `chi scratch` session: deleted when its REPL ends, and by the next
|
|
87
89
|
# sweep (or `chi sessions clean`) when the process died first.
|
|
88
90
|
attr_accessor :scratch
|
|
89
|
-
# How the last turn ended, for
|
|
90
|
-
# it in the summary)
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
|
|
91
|
+
# How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
|
|
92
|
+
# and the web's notifications (the hub sends it in the summary); nil
|
|
93
|
+
# before the first turn.
|
|
94
|
+
attr_reader :last_turn
|
|
95
|
+
|
|
96
|
+
# @param value [LastTurn, Hash, nil] a file's Hash is read
|
|
97
|
+
# (LastTurn.from_file)
|
|
98
|
+
def last_turn=(value)
|
|
99
|
+
@last_turn = LastTurn.from_file(value)
|
|
100
|
+
end
|
|
97
101
|
# The session's own llm_context values (LLMContextOverride: strategy,
|
|
98
102
|
# apply rule, budget), before the model's; nil when it has none.
|
|
99
103
|
# Saved, so a --resume and a respawned worker keep them; a plugin's
|
|
@@ -115,13 +119,28 @@ module Samagotchi
|
|
|
115
119
|
saved.empty? ? nil : saved
|
|
116
120
|
end
|
|
117
121
|
|
|
122
|
+
# The session's own thinking level (:off, :low, :medium, :high), before
|
|
123
|
+
# the process default and the model's (Thinking.resolve); nil when it
|
|
124
|
+
# has none. Saved, so a --resume, a respawned worker and a continue keep
|
|
125
|
+
# it; a plugin's fork copies it, a delegate child starts without.
|
|
126
|
+
attr_reader :thinking
|
|
127
|
+
|
|
128
|
+
# @param value [Symbol, String, nil] a value that isn't a level reads
|
|
129
|
+
# as nil and is saved back as written, until the session sets its own
|
|
130
|
+
def thinking=(value)
|
|
131
|
+
@thinking = Thinking.session_level(value)
|
|
132
|
+
unread = @thinking.nil? && value.is_a?(String) && !value.strip.casecmp?(Thinking::DEFAULT.to_s)
|
|
133
|
+
@thinking_unread = unread ? value : nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The session file's "thinking": the level, else a value it couldn't
|
|
137
|
+
# read; nil when there is neither.
|
|
138
|
+
def thinking_file = @thinking&.to_s || @thinking_unread
|
|
139
|
+
|
|
118
140
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
119
|
-
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
120
|
-
# and symbols are read (the file's keys are strings).
|
|
141
|
+
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
121
142
|
def stopped_by
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
(@last_turn["stopped_by"] || @last_turn[:stopped_by])&.to_s
|
|
143
|
+
@last_turn&.stopped_by&.to_s
|
|
125
144
|
end
|
|
126
145
|
# Archived (ArchiveStore): hidden from the lists. Set by .list (with
|
|
127
146
|
# include_archived); not saved in session.json.
|
|
@@ -146,7 +165,7 @@ module Samagotchi
|
|
|
146
165
|
used_memory_names: [], project_root: nil,
|
|
147
166
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
148
167
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
149
|
-
continues: nil)
|
|
168
|
+
continues: nil, thinking: nil)
|
|
150
169
|
@id = id
|
|
151
170
|
@metadata_version = metadata_version
|
|
152
171
|
@mode = mode
|
|
@@ -168,10 +187,11 @@ module Samagotchi
|
|
|
168
187
|
@project_root = project_root
|
|
169
188
|
@parent_id = parent_id&.to_s
|
|
170
189
|
@scratch = !!scratch
|
|
171
|
-
|
|
190
|
+
self.last_turn = last_turn
|
|
172
191
|
@delegate = !!delegate
|
|
173
192
|
@continues = continues&.to_s
|
|
174
193
|
self.llm_context = llm_context
|
|
194
|
+
self.thinking = thinking
|
|
175
195
|
@archived = false
|
|
176
196
|
end
|
|
177
197
|
|
|
@@ -220,9 +240,10 @@ module Samagotchi
|
|
|
220
240
|
# Build a new, unsaved session.
|
|
221
241
|
# @param messages [Array<Hash>] a conversation to start from (a fork's
|
|
222
242
|
# seed); [] by default
|
|
243
|
+
# @param setup [SessionSetup] the session's own settings (none by default)
|
|
223
244
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
224
245
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
225
|
-
scratch: false, model_typed: nil, delegate: false,
|
|
246
|
+
scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil)
|
|
226
247
|
now = Time.now.iso8601(3)
|
|
227
248
|
resolved_test = if test_run.nil?
|
|
228
249
|
test_session_env?
|
|
@@ -247,7 +268,8 @@ module Samagotchi
|
|
|
247
268
|
parent_id: parent_id,
|
|
248
269
|
scratch: scratch,
|
|
249
270
|
delegate: delegate,
|
|
250
|
-
llm_context: llm_context,
|
|
271
|
+
llm_context: setup.llm_context,
|
|
272
|
+
thinking: setup.thinking,
|
|
251
273
|
continues: continues
|
|
252
274
|
)
|
|
253
275
|
end
|
|
@@ -281,7 +303,8 @@ module Samagotchi
|
|
|
281
303
|
# value for a file without it (REQUIRED for the ones it must have).
|
|
282
304
|
# .from_h and #to_h both go by this table; messages and
|
|
283
305
|
# pending_question change key type on the way (symbols in memory,
|
|
284
|
-
# strings on disk)
|
|
306
|
+
# strings on disk); last_turn and prompt_notes are value objects in
|
|
307
|
+
# memory (LastTurn, PromptNote).
|
|
285
308
|
FIELDS = {
|
|
286
309
|
"metadata_version" => 1,
|
|
287
310
|
"id" => REQUIRED,
|
|
@@ -307,7 +330,8 @@ module Samagotchi
|
|
|
307
330
|
"last_turn" => nil,
|
|
308
331
|
"delegate" => false,
|
|
309
332
|
"llm_context" => nil,
|
|
310
|
-
"continues" => nil
|
|
333
|
+
"continues" => nil,
|
|
334
|
+
"thinking" => nil
|
|
311
335
|
}.freeze
|
|
312
336
|
|
|
313
337
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -428,7 +452,9 @@ module Samagotchi
|
|
|
428
452
|
when "test_run" then !!@test_run
|
|
429
453
|
when "delegate" then @delegate
|
|
430
454
|
when "llm_context" then llm_context_file
|
|
455
|
+
when "thinking" then thinking_file
|
|
431
456
|
when "prompt_notes" then @prompt_notes.map(&:to_file)
|
|
457
|
+
when "last_turn" then @last_turn&.to_file
|
|
432
458
|
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
433
459
|
Array(instance_variable_get(:"@#{key}"))
|
|
434
460
|
else instance_variable_get(:"@#{key}")
|
|
@@ -522,11 +548,6 @@ module Samagotchi
|
|
|
522
548
|
File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
|
|
523
549
|
end
|
|
524
550
|
|
|
525
|
-
# Default sessions directory path.
|
|
526
|
-
def self.default_sessions_dir
|
|
527
|
-
default_state_dir
|
|
528
|
-
end
|
|
529
|
-
|
|
530
551
|
# XDG-aware sessions directory.
|
|
531
552
|
def self.default_state_dir(env: ENV)
|
|
532
553
|
File.join(Paths.state_home(env: env), STATE_SUBDIR)
|
|
@@ -11,6 +11,7 @@ require_relative "tools/context_read"
|
|
|
11
11
|
require_relative "tools/peers"
|
|
12
12
|
require_relative "commands/registry"
|
|
13
13
|
require_relative "llm_context_command"
|
|
14
|
+
require_relative "thinking_command"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# The session commands a REPL and a session worker both run: /model,
|
|
@@ -29,6 +30,7 @@ module Samagotchi
|
|
|
29
30
|
CONTEXT_COMMAND = "/context"
|
|
30
31
|
HELP_COMMAND = "/help"
|
|
31
32
|
LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
|
|
33
|
+
THINKING_COMMAND = ThinkingCommand::NAME
|
|
32
34
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
33
35
|
# shows this many per host; /models <text> lists every match.
|
|
34
36
|
MODELS_PER_HOST = 20
|
|
@@ -88,6 +90,8 @@ module Samagotchi
|
|
|
88
90
|
# Setting it saves the session; it takes effect at the next turn's start.
|
|
89
91
|
registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
|
|
90
92
|
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) }
|
|
93
|
+
registry.register(THINKING_COMMAND, "show or set this session's thinking level (off|low|medium|high|default)",
|
|
94
|
+
id: :thinking, mid_turn: show_mid_turn(THINKING_COMMAND), match: ->(text) { text.match?(%r{\A/thinking(?:\s+.*)?\z}) }) { |text| thinking(text) }
|
|
91
95
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
92
96
|
registry.register("/stats", "show the session's stats", local: true)
|
|
93
97
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -102,11 +106,12 @@ module Samagotchi
|
|
|
102
106
|
match: ->(text) { text.casecmp?("/detach") })
|
|
103
107
|
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
104
108
|
# themselves (Formatting#steer_choice) before any command check; the
|
|
105
|
-
# entries exist for Tab completion and /help.
|
|
106
|
-
# after-the-turn list only in the local
|
|
107
|
-
# worker doesn't skip queued files yet), so
|
|
109
|
+
# entries exist for Tab completion and /help. The web composer sends
|
|
110
|
+
# /cut too. /queue is a real after-the-turn list only in the local
|
|
111
|
+
# REPL (the attached TUI's worker doesn't skip queued files yet), so
|
|
112
|
+
# it is offered there.
|
|
108
113
|
registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
|
|
109
|
-
local: true, match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
114
|
+
local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
110
115
|
registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
|
|
111
116
|
local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
|
|
112
117
|
registry
|
|
@@ -136,6 +141,34 @@ module Samagotchi
|
|
|
136
141
|
@builtin_registry ||= register_builtins(Commands::Registry.new).freeze
|
|
137
142
|
end
|
|
138
143
|
|
|
144
|
+
# The UI-only built-in command +text+ is a line of (/stats, /exit, …), or
|
|
145
|
+
# nil: neither a worker nor a one-shot run (chi send, chi -p
|
|
146
|
+
# --non-interactive) runs it, so they refuse it rather than send it to
|
|
147
|
+
# the model as a prompt. What the UIs run: the line as the entry's match
|
|
148
|
+
# takes it — bare "exit" too (/exit's match), but not with arguments
|
|
149
|
+
# ("/exit --delete" is a UI line; "/stats please" is a prompt), as in a
|
|
150
|
+
# terminal.
|
|
151
|
+
# @return [Commands::Registry::Entry, nil]
|
|
152
|
+
def self.ui_only_entry(text)
|
|
153
|
+
line = text.to_s.strip
|
|
154
|
+
return nil unless line.start_with?("/", "!") || line.match?(/\Aexit\z/i)
|
|
155
|
+
|
|
156
|
+
entry = builtin_registry.lookup_local(line)
|
|
157
|
+
return nil unless entry
|
|
158
|
+
return entry if line == entry.name
|
|
159
|
+
|
|
160
|
+
line.match?(/\Aexit\z/i) ? entry : nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Why a UI-only command isn't run by +runner+ ("chi send"): /stats points
|
|
164
|
+
# at its model-free reader (naming the session +id+ when there is one).
|
|
165
|
+
# @return [String]
|
|
166
|
+
def self.ui_only_refusal(name, id, runner:)
|
|
167
|
+
return "#{name} is a terminal and web command; #{runner} doesn't run it" unless name == "/stats"
|
|
168
|
+
|
|
169
|
+
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
170
|
+
end
|
|
171
|
+
|
|
139
172
|
# The name of this chi's own session command +line+ is (one a worker
|
|
140
173
|
# runs), or nil. A worker that answers such a line unknown_command runs
|
|
141
174
|
# an older chi that doesn't have it yet (/llm-context against 0.39.0):
|
|
@@ -175,13 +208,26 @@ module Samagotchi
|
|
|
175
208
|
notes = []
|
|
176
209
|
notes << entry.source unless entry.source == "core"
|
|
177
210
|
notes << MID_TURN_NOTES[entry.mid_turn_label] if MID_TURN_NOTES[entry.mid_turn_label]
|
|
178
|
-
notes <<
|
|
211
|
+
notes << "#{ui_words(entry.uis)} only" if entry.local
|
|
179
212
|
line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
|
|
180
213
|
notes.empty? ? line : "#{line} (#{notes.join("; ")})"
|
|
181
214
|
end
|
|
182
215
|
"commands:\n#{lines.join("\n")}"
|
|
183
216
|
end
|
|
184
217
|
|
|
218
|
+
# Where a UI's own entry runs, for /help: both terminals are
|
|
219
|
+
# "terminal" (also for no uis: every terminal), then "web".
|
|
220
|
+
# @param uis [Array<Symbol>, nil]
|
|
221
|
+
# @return [String] "terminal", "repl", "terminal and web" …
|
|
222
|
+
def ui_words(uis)
|
|
223
|
+
return "terminal" if uis.nil?
|
|
224
|
+
|
|
225
|
+
terminals = uis & %i[repl attached]
|
|
226
|
+
words = terminals.size == 2 ? ["terminal"] : terminals.map(&:to_s)
|
|
227
|
+
words << "web" if uis.include?(:web)
|
|
228
|
+
words.join(" and ")
|
|
229
|
+
end
|
|
230
|
+
|
|
185
231
|
# How help and errors name an entry (the shell's "!" is "!<cmd>").
|
|
186
232
|
def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
|
|
187
233
|
|
|
@@ -372,14 +418,25 @@ module Samagotchi
|
|
|
372
418
|
# /llm-context (LLMContextCommand); :llm_context in changed when it set
|
|
373
419
|
# the session's values.
|
|
374
420
|
def llm_context(text)
|
|
375
|
-
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) {
|
|
421
|
+
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
376
422
|
.run(text.delete_prefix(LLM_CONTEXT_COMMAND))
|
|
377
423
|
reply(output, changed: changed ? [:llm_context] : [])
|
|
378
424
|
rescue ArgumentError => e
|
|
379
425
|
reply(e.message, status: :error)
|
|
380
426
|
end
|
|
381
427
|
|
|
382
|
-
|
|
428
|
+
# /thinking (ThinkingCommand); :thinking in changed when it set the
|
|
429
|
+
# session's level.
|
|
430
|
+
def thinking(text)
|
|
431
|
+
output, changed = ThinkingCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
432
|
+
.run(text.delete_prefix(THINKING_COMMAND))
|
|
433
|
+
reply(output, changed: changed ? [:thinking] : [])
|
|
434
|
+
rescue ArgumentError => e
|
|
435
|
+
reply(e.message, status: :error)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# A session setting changed (/llm-context, /thinking): saved with its model.
|
|
439
|
+
def save_setup(session)
|
|
383
440
|
@engine.store_model!(session)
|
|
384
441
|
@save.call(session)
|
|
385
442
|
end
|
|
@@ -459,7 +516,7 @@ module Samagotchi
|
|
|
459
516
|
|
|
460
517
|
key = alias_name.strip.downcase
|
|
461
518
|
warn_prefix = previous ? "warning: overwriting alias '#{key}' (#{previous} -> #{model_name}); " : ""
|
|
462
|
-
base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated" : "runtime model set to #{model_name}#{model_note}"
|
|
519
|
+
base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}" : "runtime model set to #{model_name}#{model_note}#{thinking_note}"
|
|
463
520
|
["#{warn_prefix}#{base}; alias '#{key}' -> '#{model_name}' persisted", true]
|
|
464
521
|
else
|
|
465
522
|
return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
|
|
@@ -468,14 +525,14 @@ module Samagotchi
|
|
|
468
525
|
return ["--default cannot be combined with clear/default/none/off", false] if persist_default
|
|
469
526
|
|
|
470
527
|
switch_model(@default_model)
|
|
471
|
-
return ["runtime model reset to #{model_name}#{model_note}#{notes_note}", true]
|
|
528
|
+
return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
|
|
472
529
|
end
|
|
473
530
|
|
|
474
531
|
switch_model(arg, persist_default: persist_default)
|
|
475
532
|
if persist_default
|
|
476
|
-
["runtime model set to #{model_name}#{model_note} and default updated#{notes_note}", true]
|
|
533
|
+
["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{notes_note}", true]
|
|
477
534
|
else
|
|
478
|
-
["runtime model set to #{model_name}#{model_note}#{notes_note}", true]
|
|
535
|
+
["runtime model set to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
|
|
479
536
|
end
|
|
480
537
|
end
|
|
481
538
|
end
|
|
@@ -183,20 +183,29 @@ module Samagotchi
|
|
|
183
183
|
Dir.glob(File.join(input_dir, "*.json"))
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
|
|
186
|
+
# A claimed input file as read: its +prompt+, +origin+ ({client_id:,
|
|
187
|
+
# enqueued_id:}, nil when it names no sender), whether its turn runs
|
|
188
|
+
# with the raised iteration limit (+no_interrupt+, --no-interrupt), its
|
|
189
|
+
# +images+ ({file:, name:} refs), and its +delivery+ ("queue" or nil; a
|
|
190
|
+
# file without the key is one an older chi wrote, so nil).
|
|
191
|
+
Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
|
|
192
|
+
def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
|
|
193
|
+
end
|
|
194
|
+
# A file that doesn't parse, or isn't a JSON object: no prompt.
|
|
195
|
+
UNREADABLE_INPUT = Input.new(prompt: nil)
|
|
196
|
+
|
|
197
|
+
# @return [Input] a claimed input file's contents; UNREADABLE_INPUT for
|
|
198
|
+
# one that isn't a JSON object
|
|
192
199
|
def self.read_input(claimed_file)
|
|
193
200
|
data = JSON.parse(File.read(claimed_file).to_s)
|
|
201
|
+
return UNREADABLE_INPUT unless data.is_a?(Hash)
|
|
202
|
+
|
|
194
203
|
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
|
|
195
204
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
|
|
206
|
+
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
|
|
198
207
|
rescue JSON::ParserError
|
|
199
|
-
|
|
208
|
+
UNREADABLE_INPUT
|
|
200
209
|
end
|
|
201
210
|
|
|
202
211
|
# Whether an unclaimed input file carries images (a mid-turn drain
|