samagotchi 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -11
- data/docs/configuration.md +63 -21
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +10 -5
- data/docs/testing.md +6 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +29 -17
- data/lib/samagotchi/bridge_client.rb +3 -1
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- 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 +56 -0
- data/lib/samagotchi/engine.rb +48 -17
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +21 -13
- data/lib/samagotchi/kernel_loop.rb +28 -2
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- 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 +92 -21
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +41 -3
- data/lib/samagotchi/session_inbox.rb +36 -7
- data/lib/samagotchi/session_manager.rb +21 -10
- data/lib/samagotchi/session_metrics.rb +25 -6
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/steer_cut.rb +21 -10
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
- data/lib/samagotchi/terminal_ui.rb +56 -8
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_runner.rb +2 -0
- data/lib/samagotchi/tools/builtin_calls.rb +33 -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 +59 -7
- data/lib/samagotchi/web/message_parts.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +6 -0
- data/lib/samagotchi/web/public/app.js +199 -48
- 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 +25 -20
- 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/session_title.js +26 -0
- data/lib/samagotchi/web/public/strip.js +4 -6
- data/lib/samagotchi/web/public/thinking_chip.js +169 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +26 -2
- data/lib/samagotchi/web/public/turn_html.js +3 -1
- 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 +44 -43
- data/lib/samagotchi/worker_inbound.rb +10 -7
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- data/lib/samagotchi.rb +1 -0
- metadata +13 -1
|
@@ -32,7 +32,7 @@ module Samagotchi
|
|
|
32
32
|
# Start a child session from +messages+ (usually ctx.messages plus
|
|
33
33
|
# more). With no prompt the child waits idle for the user; with one it
|
|
34
34
|
# runs it as its first turn, and counts against session.max_children.
|
|
35
|
-
# It keeps this session's own llm_context values
|
|
35
|
+
# It keeps this session's own setup (SessionSetup: its llm_context values).
|
|
36
36
|
# Each image a message names is copied into the child, or dropped with
|
|
37
37
|
# a note in its message when its file is gone.
|
|
38
38
|
# @param messages [Array<Hash>] the child's conversation to start with
|
|
@@ -54,7 +54,7 @@ module Samagotchi
|
|
|
54
54
|
prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
|
|
55
55
|
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
|
|
56
56
|
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
|
|
57
|
-
|
|
57
|
+
setup: @host.setup&.call || SessionSetup.new
|
|
58
58
|
)
|
|
59
59
|
child.id
|
|
60
60
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session command on its way from the Bridge (POST /command, a command
|
|
7
|
+
# sent as a message, the worker's own Bridge#queue_command) to the
|
|
8
|
+
# worker's command queue. +card+: a card's action (the step-limit
|
|
9
|
+
# question's answer), whose line the UIs leave out. The worker adds
|
|
10
|
+
# where it waits (Worker#on_command): +mid_turn+ (the policy it got:
|
|
11
|
+
# :queue, :refuse, :loop), +after_seq+ (the event count then, so a
|
|
12
|
+
# turn's end knows it came before) and +after_file+ (the input file it
|
|
13
|
+
# runs after, a basename).
|
|
14
|
+
QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file) do
|
|
15
|
+
def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil) = super
|
|
16
|
+
|
|
17
|
+
# A new command with a fresh id.
|
|
18
|
+
def self.mint(line, client_id: nil, card: false)
|
|
19
|
+
new(command_id: SecureRandom.uuid, client_id: client_id, line: line, card: card ? true : false)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The fields its command_queued and command_ran name: card only when
|
|
23
|
+
# it is a card's.
|
|
24
|
+
# @return [Hash]
|
|
25
|
+
def event_fields
|
|
26
|
+
fields = { command_id: command_id, client_id: client_id, line: line }
|
|
27
|
+
fields[:card] = true if card
|
|
28
|
+
fields
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether it waits for the running turn's end (its line was shown at
|
|
32
|
+
# its command_queued).
|
|
33
|
+
def waits_for_turn_end? = mid_turn == :queue
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -104,21 +104,6 @@ module Samagotchi
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# Mark a reminder as fired — resets its next_fire_at to now + interval.
|
|
108
|
-
# Called by Engine after a due reminder has been delivered.
|
|
109
|
-
# @param name [String] the reminder name
|
|
110
|
-
def mark_fired(name)
|
|
111
|
-
name = name.to_s.strip
|
|
112
|
-
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
-
@mutex.synchronize do
|
|
114
|
-
r = @reminders[name]
|
|
115
|
-
if r
|
|
116
|
-
r[:last_fire_at] = now
|
|
117
|
-
r[:next_fire_at] = now + (r[:interval_minutes] * 60)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
107
|
# Mark multiple reminders as fired — resets each next_fire_at to now + interval.
|
|
123
108
|
# Called by Engine after injecting multiple due reminders (batch operation).
|
|
124
109
|
# Thread-safe, single lock acquisition.
|
|
@@ -137,18 +122,6 @@ module Samagotchi
|
|
|
137
122
|
end
|
|
138
123
|
end
|
|
139
124
|
|
|
140
|
-
# Get the description of a due reminder (for injection into the system prompt).
|
|
141
|
-
# Called by Engine during run_turn. Thread-safe.
|
|
142
|
-
# @param name [String] the reminder name
|
|
143
|
-
# @return [String, nil] the description, or nil if not found
|
|
144
|
-
def get_description(name)
|
|
145
|
-
name = name.to_s.strip
|
|
146
|
-
@mutex.synchronize do
|
|
147
|
-
r = @reminders[name]
|
|
148
|
-
r[:description] if r
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
125
|
# Clear all reminders.
|
|
153
126
|
# Called when the session ends or the engine is reset.
|
|
154
127
|
def clear_all
|
|
@@ -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
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
|
+
require_relative "delivery"
|
|
5
|
+
require_relative "terminal_ui/formatting"
|
|
4
6
|
require_relative "session"
|
|
5
7
|
require_relative "session_commands"
|
|
6
8
|
require_relative "session_inbox"
|
|
@@ -32,15 +34,18 @@ module Samagotchi
|
|
|
32
34
|
CLIENT_ID = ClientId::CLI_SEND
|
|
33
35
|
# The cap on one turn's images the Bridge checks.
|
|
34
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
|
|
35
39
|
|
|
36
40
|
USAGE = <<~TEXT
|
|
37
|
-
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
38
|
-
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
|
|
41
|
+
Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
|
|
42
|
+
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
|
|
39
43
|
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
40
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
41
45
|
chi send --wait [--timeout S] [--format json] ID
|
|
42
46
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
43
|
-
or a running one
|
|
47
|
+
or a running one takes it at its next step. A stopped session's
|
|
48
|
+
worker starts.
|
|
44
49
|
A session command (/model x, !cmd) runs as the command; --wait
|
|
45
50
|
then has no reply to wait for (exit 0, status command).
|
|
46
51
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
@@ -57,16 +62,20 @@ module Samagotchi
|
|
|
57
62
|
the current one
|
|
58
63
|
--continues ID
|
|
59
64
|
(--new) start the next link of that session's chain:
|
|
60
|
-
in its folder, on its model
|
|
61
|
-
note carrying its recap; it
|
|
62
|
-
names the chain's latest link.
|
|
63
|
-
session waits idle. Refused for a
|
|
64
|
-
already (naming the next link) or
|
|
65
|
-
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
|
|
66
71
|
--model M (--new) its model; default the configured one
|
|
67
72
|
--llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
|
|
68
73
|
(--new) its own LLM context strategy (none, stale,forget),
|
|
69
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
|
|
70
79
|
--wait wait for the answer and print it (one session); the
|
|
71
80
|
other lines go to stderr. Exit 3: it waits for an
|
|
72
81
|
answer from you: the question, its options and the
|
|
@@ -75,6 +84,14 @@ module Samagotchi
|
|
|
75
84
|
nothing is sent: it waits for the session's next
|
|
76
85
|
reply, a running turn's too (after exit 3, 4 or 130,
|
|
77
86
|
wait again this way)
|
|
87
|
+
--cut ask the running turn to cut the generation it is
|
|
88
|
+
streaming and take the message now (a generation that
|
|
89
|
+
has streamed only thinking for steer.cut_after; else
|
|
90
|
+
the message waits for the thinking to pass it). A
|
|
91
|
+
message without --cut goes in at the running turn's
|
|
92
|
+
next step and cuts nothing. To an idle session it
|
|
93
|
+
sends normally. Refused with --wait into a running
|
|
94
|
+
turn (the steer's turn is the running one)
|
|
78
95
|
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
79
96
|
goes on); default no limit
|
|
80
97
|
--format json
|
|
@@ -82,7 +99,8 @@ module Samagotchi
|
|
|
82
99
|
status answered (text), question (question,
|
|
83
100
|
answer_with), running, or failed, canceled,
|
|
84
101
|
limit, no_answer, error, worker_gone, stopped,
|
|
85
|
-
command (detail)
|
|
102
|
+
command (detail); with --cut also delivery (what
|
|
103
|
+
the worker applied) and cut (now, waits, off)
|
|
86
104
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
87
105
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
88
106
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -99,8 +117,10 @@ module Samagotchi
|
|
|
99
117
|
f.value "--llm-context", key: :llm_strategy
|
|
100
118
|
f.value "--llm-context-apply", key: :llm_apply
|
|
101
119
|
f.value "--llm-context-budget", key: :llm_budget
|
|
120
|
+
f.value "--thinking"
|
|
102
121
|
f.value "--image", key: :images, repeat: true
|
|
103
122
|
f.switch "--wait"
|
|
123
|
+
f.switch "--cut"
|
|
104
124
|
f.value "--timeout"
|
|
105
125
|
f.value "--format"
|
|
106
126
|
# Starting a turn in every live session at once is too easy to do
|
|
@@ -160,6 +180,7 @@ module Samagotchi
|
|
|
160
180
|
def run_parsed(options)
|
|
161
181
|
# With --wait stdout is the answer alone.
|
|
162
182
|
@info = options[:wait] ? @stderr : @stdout
|
|
183
|
+
@cut = options[:cut] == true
|
|
163
184
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
164
185
|
# Before the wait-only case, which would drop the images.
|
|
165
186
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
@@ -229,7 +250,7 @@ module Samagotchi
|
|
|
229
250
|
return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
|
|
230
251
|
return new_options(options) if options[:new]
|
|
231
252
|
|
|
232
|
-
%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] }
|
|
233
254
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
234
255
|
return usage_error("give session ids") if options[:ids].empty?
|
|
235
256
|
|
|
@@ -246,9 +267,9 @@ module Samagotchi
|
|
|
246
267
|
|
|
247
268
|
if options[:continues]
|
|
248
269
|
taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
|
|
249
|
-
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])]
|
|
250
271
|
unless taken.compact.empty?
|
|
251
|
-
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; " \
|
|
252
273
|
"leave out #{taken.compact.join(", ")}")
|
|
253
274
|
end
|
|
254
275
|
end
|
|
@@ -260,6 +281,15 @@ module Samagotchi
|
|
|
260
281
|
return usage_error(e.message)
|
|
261
282
|
end
|
|
262
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
|
+
|
|
263
293
|
if options[:dir]
|
|
264
294
|
options[:dir] = File.expand_path(options[:dir])
|
|
265
295
|
return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
|
|
@@ -304,7 +334,9 @@ module Samagotchi
|
|
|
304
334
|
SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
|
|
305
335
|
else
|
|
306
336
|
SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
307
|
-
model_name: options[:model],
|
|
337
|
+
model_name: options[:model],
|
|
338
|
+
setup: SessionSetup.new(llm_context: options[:llm_context],
|
|
339
|
+
thinking: options[:thinking]),
|
|
308
340
|
state_dir: @state_dir)
|
|
309
341
|
end
|
|
310
342
|
rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
|
@@ -527,7 +559,11 @@ module Samagotchi
|
|
|
527
559
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
528
560
|
running = owner && session.status == Session::STATUS_RUNNING
|
|
529
561
|
refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
|
|
530
|
-
|
|
562
|
+
# --cut asks the running turn for a cut; to an idle session it is an
|
|
563
|
+
# ordinary message (there is nothing to cut).
|
|
564
|
+
delivery = @cut && running ? Delivery::CUT : nil
|
|
565
|
+
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
|
|
566
|
+
delivery: delivery)
|
|
531
567
|
unless result[:status] == :accepted
|
|
532
568
|
@info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
|
|
533
569
|
return false
|
|
@@ -539,12 +575,12 @@ module Samagotchi
|
|
|
539
575
|
return true
|
|
540
576
|
end
|
|
541
577
|
|
|
542
|
-
|
|
543
|
-
#
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
@info.puts("#{short} sent#{with_images}#{
|
|
578
|
+
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
579
|
+
# What the worker applied, for --format json: its ack's delivery
|
|
580
|
+
# (next_step for an idle session) and a cut's outcome.
|
|
581
|
+
@sent_delivery = ack["delivery"] || delivery
|
|
582
|
+
@sent_cut = ack["cut"]
|
|
583
|
+
@info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
|
|
548
584
|
true
|
|
549
585
|
rescue SessionManager::OwnedByTUI
|
|
550
586
|
@info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
|
|
@@ -554,6 +590,41 @@ module Samagotchi
|
|
|
554
590
|
false
|
|
555
591
|
end
|
|
556
592
|
|
|
593
|
+
# What the line says became of the message: a plain one goes in at the
|
|
594
|
+
# running turn's next step (nothing cuts by default); a --cut one names
|
|
595
|
+
# the outcome from the ack (now, waits, off); images keep their own
|
|
596
|
+
# wording; an idle session's turn simply runs.
|
|
597
|
+
# @param ack [Hash] the Bridge's 202 body ({} for the input file)
|
|
598
|
+
# @return [String] "" or " (...)"
|
|
599
|
+
def delivery_note(ack, delivery:, running:, owner:)
|
|
600
|
+
return " (started its worker)" if owner.nil?
|
|
601
|
+
return "" unless running
|
|
602
|
+
|
|
603
|
+
# A busy worker runs a message with images as its own next turn
|
|
604
|
+
# rather than merging it into the running one.
|
|
605
|
+
return " (runs after the current turn)" unless @images.empty?
|
|
606
|
+
|
|
607
|
+
# The TUIs' words for a cut's outcome. No outcome: a plain message,
|
|
608
|
+
# or the input file (the Bridge went away), which the next step's
|
|
609
|
+
# drain takes.
|
|
610
|
+
outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
|
|
611
|
+
return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
|
|
612
|
+
|
|
613
|
+
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym)}"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# More keys for --format json, with --cut only (the line is unchanged
|
|
617
|
+
# otherwise): the delivery the worker applied (next_step to an idle
|
|
618
|
+
# session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
|
|
619
|
+
# refused for a running turn, so the json line says next_step.
|
|
620
|
+
def report_extra
|
|
621
|
+
return {} unless @cut
|
|
622
|
+
|
|
623
|
+
extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
|
|
624
|
+
extra[:cut] = @sent_cut if @sent_cut
|
|
625
|
+
extra
|
|
626
|
+
end
|
|
627
|
+
|
|
557
628
|
# What to say when +model_name+ is known not to take images, or nil
|
|
558
629
|
# (it does, or it is unknown: sent as before). Asked once per model.
|
|
559
630
|
def vision_refusal(model_name, typed: nil)
|
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)
|