samagotchi 0.51.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 +46 -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/sessions.md +5 -1
- 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.rb +10 -13
- 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 +23 -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 +5 -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/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 +37 -19
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +36 -7
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +8 -7
- data/lib/samagotchi/session_setup.rb +30 -0
- 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 +21 -3
- data/lib/samagotchi/terminal_ui.rb +8 -3
- 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 +54 -6
- data/lib/samagotchi/web/public/app.js +133 -20
- 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 +169 -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 +11 -1
|
@@ -20,15 +20,15 @@ module Samagotchi
|
|
|
20
20
|
# the answer text.
|
|
21
21
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
22
|
# +model_name+ (the session's resolved model ref, live after /model) and
|
|
23
|
-
# +state_dir+ are what ctx.sessions forks with, and +
|
|
24
|
-
# session's own
|
|
23
|
+
# +state_dir+ are what ctx.sessions forks with, and +setup+ (the
|
|
24
|
+
# session's own SessionSetup), which a fork copies; +model_key+ is the
|
|
25
25
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
26
26
|
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
27
27
|
# (reason, label), each true when it acted on a running turn.
|
|
28
28
|
# +frontend+ returns the Engine's interface (:repl, :worker,
|
|
29
29
|
# :non_interactive), which ctx.frontend names for plugins.
|
|
30
30
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
31
|
-
:ask_model, :model_name, :model_key, :state_dir, :scratch, :
|
|
31
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :steer, :stop_turn, :stop_generation,
|
|
32
32
|
:frontend, keyword_init: true)
|
|
33
33
|
|
|
34
34
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
@@ -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
|
|
@@ -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
|
|
@@ -585,14 +604,13 @@ module Samagotchi
|
|
|
585
604
|
# rather than merging it into the running one.
|
|
586
605
|
return " (runs after the current turn)" unless @images.empty?
|
|
587
606
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
end
|
|
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)}"
|
|
596
614
|
end
|
|
597
615
|
|
|
598
616
|
# 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
|
|
@@ -175,13 +180,26 @@ module Samagotchi
|
|
|
175
180
|
notes = []
|
|
176
181
|
notes << entry.source unless entry.source == "core"
|
|
177
182
|
notes << MID_TURN_NOTES[entry.mid_turn_label] if MID_TURN_NOTES[entry.mid_turn_label]
|
|
178
|
-
notes <<
|
|
183
|
+
notes << "#{ui_words(entry.uis)} only" if entry.local
|
|
179
184
|
line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
|
|
180
185
|
notes.empty? ? line : "#{line} (#{notes.join("; ")})"
|
|
181
186
|
end
|
|
182
187
|
"commands:\n#{lines.join("\n")}"
|
|
183
188
|
end
|
|
184
189
|
|
|
190
|
+
# Where a UI's own entry runs, for /help: both terminals are
|
|
191
|
+
# "terminal" (also for no uis: every terminal), then "web".
|
|
192
|
+
# @param uis [Array<Symbol>, nil]
|
|
193
|
+
# @return [String] "terminal", "repl", "terminal and web" …
|
|
194
|
+
def ui_words(uis)
|
|
195
|
+
return "terminal" if uis.nil?
|
|
196
|
+
|
|
197
|
+
terminals = uis & %i[repl attached]
|
|
198
|
+
words = terminals.size == 2 ? ["terminal"] : terminals.map(&:to_s)
|
|
199
|
+
words << "web" if uis.include?(:web)
|
|
200
|
+
words.join(" and ")
|
|
201
|
+
end
|
|
202
|
+
|
|
185
203
|
# How help and errors name an entry (the shell's "!" is "!<cmd>").
|
|
186
204
|
def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
|
|
187
205
|
|
|
@@ -372,14 +390,25 @@ module Samagotchi
|
|
|
372
390
|
# /llm-context (LLMContextCommand); :llm_context in changed when it set
|
|
373
391
|
# the session's values.
|
|
374
392
|
def llm_context(text)
|
|
375
|
-
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) {
|
|
393
|
+
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
376
394
|
.run(text.delete_prefix(LLM_CONTEXT_COMMAND))
|
|
377
395
|
reply(output, changed: changed ? [:llm_context] : [])
|
|
378
396
|
rescue ArgumentError => e
|
|
379
397
|
reply(e.message, status: :error)
|
|
380
398
|
end
|
|
381
399
|
|
|
382
|
-
|
|
400
|
+
# /thinking (ThinkingCommand); :thinking in changed when it set the
|
|
401
|
+
# session's level.
|
|
402
|
+
def thinking(text)
|
|
403
|
+
output, changed = ThinkingCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
404
|
+
.run(text.delete_prefix(THINKING_COMMAND))
|
|
405
|
+
reply(output, changed: changed ? [:thinking] : [])
|
|
406
|
+
rescue ArgumentError => e
|
|
407
|
+
reply(e.message, status: :error)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# A session setting changed (/llm-context, /thinking): saved with its model.
|
|
411
|
+
def save_setup(session)
|
|
383
412
|
@engine.store_model!(session)
|
|
384
413
|
@save.call(session)
|
|
385
414
|
end
|