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
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "rbconfig"
|
|
8
8
|
require_relative "client_id"
|
|
9
|
+
require_relative "analytics_file"
|
|
9
10
|
require_relative "atomic_file"
|
|
10
11
|
require_relative "config"
|
|
11
12
|
require_relative "delivery"
|
|
@@ -14,6 +15,7 @@ require_relative "session"
|
|
|
14
15
|
require_relative "session_inbox"
|
|
15
16
|
require_relative "session_metrics"
|
|
16
17
|
require_relative "llm_context_strategy"
|
|
18
|
+
require_relative "session_setup"
|
|
17
19
|
require_relative "host_registry"
|
|
18
20
|
require_relative "utf8_default"
|
|
19
21
|
require_relative "turn_note"
|
|
@@ -163,8 +165,8 @@ module Samagotchi
|
|
|
163
165
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
164
166
|
# @param title [String, nil] what the lists show before the first turn
|
|
165
167
|
# (the prompt's preview by default, else the seed's first user message)
|
|
166
|
-
# @param
|
|
167
|
-
#
|
|
168
|
+
# @param setup [SessionSetup] the session's own settings (chi
|
|
169
|
+
# --llm-context; a fork and a continue copy their session's)
|
|
168
170
|
# @param continues [String, nil] the session this one continues (a
|
|
169
171
|
# session field; #continue_session)
|
|
170
172
|
# @param note [String, nil] a context note the session starts with,
|
|
@@ -176,7 +178,7 @@ module Samagotchi
|
|
|
176
178
|
# and #model_warning: an id its host's saved list doesn't have
|
|
177
179
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
178
180
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
179
|
-
title: nil, delegate: false,
|
|
181
|
+
title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
180
182
|
note_source: "chi", before_spawn: nil)
|
|
181
183
|
sd = state_dir || Session.default_state_dir
|
|
182
184
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
@@ -198,7 +200,7 @@ module Samagotchi
|
|
|
198
200
|
parent_id: parent_id,
|
|
199
201
|
messages: messages,
|
|
200
202
|
delegate: delegate,
|
|
201
|
-
|
|
203
|
+
setup: setup,
|
|
202
204
|
continues: continues
|
|
203
205
|
)
|
|
204
206
|
session.model_warning = model_warning
|
|
@@ -232,7 +234,7 @@ module Samagotchi
|
|
|
232
234
|
|
|
233
235
|
# Start the next link of a chain: a new session that continues
|
|
234
236
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
235
|
-
# it was typed as, so an alias holds) and with its own
|
|
237
|
+
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
236
238
|
# starting with a context note from chi: the link and the previous
|
|
237
239
|
# link's recap (SessionChain.note_text). The previous link is archived
|
|
238
240
|
# first, with its finished delegates (archive_session's rules: a turn
|
|
@@ -363,7 +365,7 @@ module Samagotchi
|
|
|
363
365
|
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
364
366
|
end
|
|
365
367
|
link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
|
|
366
|
-
model_name: previous.model_typed || previous.model_name,
|
|
368
|
+
model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
|
|
367
369
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
368
370
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
|
|
369
371
|
link.moved_children = move
|
|
@@ -515,7 +517,7 @@ module Samagotchi
|
|
|
515
517
|
|
|
516
518
|
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
517
519
|
# Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
|
|
518
|
-
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
520
|
+
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
|
|
519
521
|
|
|
520
522
|
# List all sessions, reading status from persisted session.json files.
|
|
521
523
|
# +project_root+: only that project's sessions (nil: every session).
|
|
@@ -973,7 +975,7 @@ module Samagotchi
|
|
|
973
975
|
# session keeps. "pid" is the pid file workers wrote until 2026-10-01:
|
|
974
976
|
# nothing writes or reads it now, but a folder from before still has one
|
|
975
977
|
# and is still empty.
|
|
976
|
-
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE,
|
|
978
|
+
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, AnalyticsFile::NAME].freeze
|
|
977
979
|
EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
|
|
978
980
|
EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
|
|
979
981
|
|
|
@@ -1057,17 +1059,48 @@ module Samagotchi
|
|
|
1057
1059
|
path = File.join(dir, name)
|
|
1058
1060
|
if EMPTY_SKELETON_DIRS.include?(name)
|
|
1059
1061
|
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
|
|
1060
|
-
elsif name ==
|
|
1061
|
-
|
|
1062
|
+
elsif name == AnalyticsFile::NAME
|
|
1063
|
+
analytics_empty?(path)
|
|
1062
1064
|
# Cards and notices from before any turn (Bridge::CardStore).
|
|
1063
1065
|
elsif name == "cards.json"
|
|
1064
|
-
|
|
1066
|
+
cards_empty?(path)
|
|
1065
1067
|
else
|
|
1066
1068
|
EMPTY_SKELETON_FILES.include?(name)
|
|
1067
1069
|
end
|
|
1068
1070
|
end
|
|
1069
1071
|
end
|
|
1070
1072
|
|
|
1073
|
+
# A folder's analytics.json is empty only when it is a JSON object with
|
|
1074
|
+
# an empty turn_records array. Anything unreadable, not JSON, not an
|
|
1075
|
+
# object, the wrong type, or turn_records the wrong shape reads as
|
|
1076
|
+
# "not empty": a broken analytics keeps the folder instead of crashing
|
|
1077
|
+
# the retention sweep (or of a wrong-shape one reading as empty).
|
|
1078
|
+
def self.analytics_empty?(path)
|
|
1079
|
+
data = read_json(path)
|
|
1080
|
+
turns = data.is_a?(Hash) ? data["turn_records"] : nil
|
|
1081
|
+
turns.is_a?(Array) && turns.empty?
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# A folder's cards.json is empty only when it is a JSON object whose
|
|
1085
|
+
# turns is an integer zero. Anything unreadable, not JSON, not an
|
|
1086
|
+
# object, the wrong type, or turns not an integer reads as "not empty":
|
|
1087
|
+
# a broken or wrong-shape cards file keeps the folder instead of
|
|
1088
|
+
# raising (Array#/Hash#to_i) or reading as empty.
|
|
1089
|
+
def self.cards_empty?(path)
|
|
1090
|
+
data = read_json(path)
|
|
1091
|
+
turns = data.is_a?(Hash) ? data["turns"] : nil
|
|
1092
|
+
turns.is_a?(Integer) && turns.zero?
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
# A session-dir JSON file's contents, or nil when it is missing,
|
|
1096
|
+
# unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
|
|
1097
|
+
# broken file reads as none, so its folder is kept, not deleted.
|
|
1098
|
+
def self.read_json(path)
|
|
1099
|
+
JSON.parse(File.read(path))
|
|
1100
|
+
rescue JSON::ParserError, SystemCallError
|
|
1101
|
+
nil
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1071
1104
|
# Delete one session: its <id>.json, the whole <id>/ directory
|
|
1072
1105
|
# (history sidecars, input, output, notes, images) and its attached
|
|
1073
1106
|
# context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
|
|
@@ -5,6 +5,7 @@ require "monitor"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "securerandom"
|
|
8
|
+
require_relative "analytics_file"
|
|
8
9
|
require_relative "atomic_file"
|
|
9
10
|
require_relative "token_usage"
|
|
10
11
|
require_relative "model_price"
|
|
@@ -172,8 +173,8 @@ module Samagotchi
|
|
|
172
173
|
# @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
|
|
173
174
|
# @return [SavedSummary, nil]
|
|
174
175
|
def self.saved_summary(session_dir, budget_tokens: nil)
|
|
175
|
-
data =
|
|
176
|
-
return nil unless data
|
|
176
|
+
data = AnalyticsFile.read(session_dir)
|
|
177
|
+
return nil unless data
|
|
177
178
|
|
|
178
179
|
tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
|
|
179
180
|
count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
|
|
@@ -182,7 +183,7 @@ module Samagotchi
|
|
|
182
183
|
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
|
|
183
184
|
cost_estimate_sum: count.call("cost_estimate_sum"),
|
|
184
185
|
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
|
|
185
|
-
rescue
|
|
186
|
+
rescue TypeError
|
|
186
187
|
nil
|
|
187
188
|
end
|
|
188
189
|
|
|
@@ -217,10 +218,10 @@ module Samagotchi
|
|
|
217
218
|
def self.saved_tool_records(session_dir, turn_id)
|
|
218
219
|
return [] if turn_id.to_s.empty?
|
|
219
220
|
|
|
220
|
-
records =
|
|
221
|
+
records = AnalyticsFile.read(session_dir)&.dig("tool_records")
|
|
221
222
|
Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
|
|
222
223
|
.sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
|
|
223
|
-
rescue
|
|
224
|
+
rescue TypeError
|
|
224
225
|
[]
|
|
225
226
|
end
|
|
226
227
|
|
|
@@ -234,9 +235,9 @@ module Samagotchi
|
|
|
234
235
|
def self.saved_turn_record(session_dir, turn_id)
|
|
235
236
|
return nil if turn_id.to_s.empty?
|
|
236
237
|
|
|
237
|
-
records =
|
|
238
|
+
records = AnalyticsFile.read(session_dir)&.dig("turn_records")
|
|
238
239
|
Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
|
|
239
|
-
rescue
|
|
240
|
+
rescue TypeError
|
|
240
241
|
nil
|
|
241
242
|
end
|
|
242
243
|
|
|
@@ -422,8 +423,7 @@ module Samagotchi
|
|
|
422
423
|
# (XDG) location; passing an explicit nil would override it and break
|
|
423
424
|
# File.join.
|
|
424
425
|
FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
|
|
425
|
-
|
|
426
|
-
AtomicFile.write(path, JSON.pretty_generate(data) + "\n")
|
|
426
|
+
AtomicFile.write(AnalyticsFile.path(dir), JSON.pretty_generate(data) + "\n")
|
|
427
427
|
@mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
|
|
428
428
|
true
|
|
429
429
|
rescue StandardError
|
|
@@ -977,11 +977,8 @@ module Samagotchi
|
|
|
977
977
|
# broken file, or an older shape: nothing, or zeros where keys are
|
|
978
978
|
# missing). Caller holds the mutex.
|
|
979
979
|
def load_persisted
|
|
980
|
-
|
|
981
|
-
return unless
|
|
982
|
-
|
|
983
|
-
prior = JSON.parse(File.read(path))
|
|
984
|
-
return unless prior.is_a?(Hash)
|
|
980
|
+
prior = AnalyticsFile.read(session_dir(@session_id, @state_dir))
|
|
981
|
+
return unless prior
|
|
985
982
|
|
|
986
983
|
loaded_turns = loaded_records(prior["turn_records"])
|
|
987
984
|
@turn_records = loaded_turns + @turn_records
|
|
@@ -1004,12 +1001,10 @@ module Samagotchi
|
|
|
1004
1001
|
@memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
|
|
1005
1002
|
# The window last seen, until this process's first generation reports.
|
|
1006
1003
|
window = prior["context"].is_a?(Hash) ? prior["context"] : {}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
rescue JSON::ParserError, SystemCallError
|
|
1012
|
-
nil
|
|
1004
|
+
return unless window["window_tokens"] && @context_window_tokens.nil?
|
|
1005
|
+
|
|
1006
|
+
@context_window_tokens = window["window_tokens"]
|
|
1007
|
+
@context_window_source = window["window_source"]
|
|
1013
1008
|
end
|
|
1014
1009
|
|
|
1015
1010
|
def symbolized(hash)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_context_override"
|
|
4
|
+
require_relative "thinking"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A session's own settings that come before its model's, carried as one
|
|
8
|
+
# value on the paths that spawn a session from a whole setup
|
|
9
|
+
# (SessionManager.spawn_session, a continue, a plugin's fork, the web's
|
|
10
|
+
# create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
|
|
11
|
+
# +thinking+ a level Symbol (Thinking.session_level) or nil.
|
|
12
|
+
#
|
|
13
|
+
# It doesn't save itself: the session keeps each setting in its own field
|
|
14
|
+
# and file key, and resolves it on its own. A delegate child starts
|
|
15
|
+
# without one; a continue and a fork copy their session's (.of).
|
|
16
|
+
SessionSetup = Data.define(:llm_context, :thinking) do
|
|
17
|
+
def initialize(llm_context: nil, thinking: nil) = super
|
|
18
|
+
|
|
19
|
+
def empty? = llm_context.nil? && thinking.nil?
|
|
20
|
+
|
|
21
|
+
# A session's own setup, for a session that starts from it.
|
|
22
|
+
# @param session [Session, nil]
|
|
23
|
+
# @return [SessionSetup]
|
|
24
|
+
def self.of(session)
|
|
25
|
+
return new if session.nil?
|
|
26
|
+
|
|
27
|
+
new(llm_context: session.llm_context, thinking: session.thinking)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -5,6 +5,7 @@ require_relative "session"
|
|
|
5
5
|
require_relative "session_manager"
|
|
6
6
|
require_relative "session_retention"
|
|
7
7
|
require_relative "project_scope"
|
|
8
|
+
require_relative "analytics_file"
|
|
8
9
|
require_relative "session_metrics"
|
|
9
10
|
require_relative "recap_store"
|
|
10
11
|
require_relative "session_delete_command"
|
|
@@ -337,10 +338,10 @@ module Samagotchi
|
|
|
337
338
|
return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
|
|
338
339
|
end
|
|
339
340
|
end
|
|
340
|
-
return [nil, false, nil] unless File.file?(
|
|
341
|
+
return [nil, false, nil] unless File.file?(Samagotchi::AnalyticsFile.path(dir))
|
|
341
342
|
|
|
342
343
|
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
|
|
343
|
-
note = live_worker?(session.id) ? "(no live worker answered; from the saved
|
|
344
|
+
note = live_worker?(session.id) ? "(no live worker answered; from the saved #{Samagotchi::AnalyticsFile::NAME})" : nil
|
|
344
345
|
[snapshot, false, note]
|
|
345
346
|
end
|
|
346
347
|
|
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -22,6 +22,13 @@ module Samagotchi
|
|
|
22
22
|
{ thinking: event[:thinking].to_s, text: text.to_s }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# steer.cut_after: how long a generation streams only thinking before a
|
|
26
|
+
# message may cut it, in whole seconds (0 or less: cutting is off).
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
def self.cut_after
|
|
29
|
+
Config.get("steer.cut_after").to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
25
32
|
# +clock+: monotonic seconds; +controller+: the running turn's cancel
|
|
26
33
|
# controller (nil between turns), read only when a cut is due.
|
|
27
34
|
def initialize(clock:, controller:)
|
|
@@ -51,7 +58,7 @@ module Samagotchi
|
|
|
51
58
|
def cut_for_steer(source, epoch: nil)
|
|
52
59
|
return :off unless Steer.cuts?(source)
|
|
53
60
|
|
|
54
|
-
after =
|
|
61
|
+
after = self.class.cut_after
|
|
55
62
|
return :off unless after.positive?
|
|
56
63
|
|
|
57
64
|
ctrl = @controller.call
|
|
@@ -104,7 +111,7 @@ module Samagotchi
|
|
|
104
111
|
def recheck
|
|
105
112
|
return unless @waiting.waiting?
|
|
106
113
|
|
|
107
|
-
after =
|
|
114
|
+
after = self.class.cut_after
|
|
108
115
|
return unless after.positive? && @phase.cuttable?(after)
|
|
109
116
|
|
|
110
117
|
source = @waiting.take
|
|
@@ -4,6 +4,7 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../config"
|
|
5
5
|
require_relative "../model_profile"
|
|
6
6
|
require_relative "../session_manager"
|
|
7
|
+
require_relative "../thinking_command"
|
|
7
8
|
require_relative "../bridge_client"
|
|
8
9
|
require_relative "../parent_report"
|
|
9
10
|
require_relative "../cli/exit"
|
|
@@ -40,16 +41,20 @@ module Samagotchi
|
|
|
40
41
|
# (LLMContextOverride.update): a new session starts with them; a
|
|
41
42
|
# resumed or attached one's worker gets a /llm-context, after the
|
|
42
43
|
# /model
|
|
44
|
+
# @param thinking [String, nil] --thinking as typed: a new session
|
|
45
|
+
# starts with it as its own level; a resumed or attached one's
|
|
46
|
+
# worker gets a /thinking, after those
|
|
43
47
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
44
48
|
# :failed when the --model switch didn't go through, or (input from a
|
|
45
49
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
46
50
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
47
|
-
memories: [], muted_memories: [], llm_context: {})
|
|
51
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil)
|
|
48
52
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
49
|
-
memories: memories, muted_memories: muted_memories, llm_context: llm_context)
|
|
53
|
+
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking)
|
|
50
54
|
commands = []
|
|
51
55
|
commands << "/model #{model}" if model && (attach || resume)
|
|
52
56
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
57
|
+
commands << "#{ThinkingCommand::NAME} #{thinking}" if (attach || resume) && thinking
|
|
53
58
|
first_command = commands.size > 1 ? commands : commands.first
|
|
54
59
|
surface = open_surface
|
|
55
60
|
attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
|
|
@@ -111,7 +116,7 @@ module Samagotchi
|
|
|
111
116
|
# @return [BridgeClient] a client for the live Bridge of the session
|
|
112
117
|
# @raise [Error]
|
|
113
118
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
114
|
-
memories: [], muted_memories: [], llm_context: {}, err: $stderr)
|
|
119
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, err: $stderr)
|
|
115
120
|
sd = state_dir || Session.default_state_dir
|
|
116
121
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
|
|
117
122
|
if attach
|
|
@@ -127,7 +132,8 @@ module Samagotchi
|
|
|
127
132
|
else
|
|
128
133
|
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
129
134
|
memories: memories, muted_memories: muted_memories,
|
|
130
|
-
llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context)
|
|
135
|
+
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
136
|
+
thinking: Thinking.session_level(thinking)))
|
|
131
137
|
end
|
|
132
138
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
133
139
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -430,7 +430,7 @@ module Samagotchi
|
|
|
430
430
|
# @return [nil]
|
|
431
431
|
def submit_delivery(choice, text)
|
|
432
432
|
return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
|
|
433
|
-
return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue
|
|
433
|
+
return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue && @running
|
|
434
434
|
|
|
435
435
|
# To an idle session it is an ordinary prompt: no delivery to send.
|
|
436
436
|
send_prompt(text, delivery: @running ? Delivery::CUT : nil)
|
|
@@ -5,6 +5,7 @@ require_relative "../served_model"
|
|
|
5
5
|
require_relative "../image_store"
|
|
6
6
|
require_relative "../turn_note"
|
|
7
7
|
require_relative "../steer"
|
|
8
|
+
require_relative "../steer_cut"
|
|
8
9
|
require_relative "../memory_bundle/index_size"
|
|
9
10
|
require_relative "../prompt_note"
|
|
10
11
|
|
|
@@ -19,7 +20,7 @@ module Samagotchi
|
|
|
19
20
|
# (no UI: the same text from the CLI).
|
|
20
21
|
# @return [String] "(no metrics yet)" without a snapshot
|
|
21
22
|
def self.format_session_metrics(snapshot)
|
|
22
|
-
(@formatter ||= Object.new.extend(Formatting)).
|
|
23
|
+
(@formatter ||= Object.new.extend(Formatting)).format_session_metrics(snapshot)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def format_tool_activity_line(activity, duration_ms: nil)
|
|
@@ -99,7 +100,8 @@ module Samagotchi
|
|
|
99
100
|
# line goes in at the running turn's next step boundary (nothing cuts
|
|
100
101
|
# unless the message asked for it), /cut's cutting now or once the
|
|
101
102
|
# thinking passes steer.cut_after, and /queue's waiting for the end.
|
|
102
|
-
# Both TUIs print the same words
|
|
103
|
+
# Both TUIs and chi send --cut print the same words
|
|
104
|
+
# (SteerCut#cut_for_steer's answer).
|
|
103
105
|
NEXT_STEP_NOTE = "(goes in at the next step)"
|
|
104
106
|
CUT_NOW_NOTE = "(cut in now)"
|
|
105
107
|
CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
|
|
@@ -115,14 +117,17 @@ module Samagotchi
|
|
|
115
117
|
|
|
116
118
|
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
117
119
|
# @return [String] the dim line to show for a /cut
|
|
118
|
-
def steer_cut_note(outcome)
|
|
120
|
+
def self.steer_cut_note(outcome)
|
|
119
121
|
case outcome
|
|
120
122
|
when :now then CUT_NOW_NOTE
|
|
121
|
-
when :waits then format(CUT_WAITS_NOTE,
|
|
123
|
+
when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
|
|
122
124
|
else CUT_OFF_NOTE
|
|
123
125
|
end
|
|
124
126
|
end
|
|
125
127
|
|
|
128
|
+
# The same words, for a UI that includes the module.
|
|
129
|
+
def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
|
|
130
|
+
|
|
126
131
|
# The choice a mid-turn line carries, as [:cut, "TEXT"] or
|
|
127
132
|
# [:queue, "TEXT"]; nil for any other line. The text is "" when the
|
|
128
133
|
# line named the choice without one.
|
|
@@ -538,6 +543,8 @@ module Samagotchi
|
|
|
538
543
|
value.length > width ? value[0, width] : value
|
|
539
544
|
end
|
|
540
545
|
|
|
546
|
+
public
|
|
547
|
+
|
|
541
548
|
# Render the analytics snapshot as a compact, user-facing report. Raw event
|
|
542
549
|
# logs (debug-only) are intentionally excluded; this surface is for the REPL
|
|
543
550
|
# and `chi sessions stats` (the module function above).
|
|
@@ -591,6 +598,8 @@ module Samagotchi
|
|
|
591
598
|
lines << "model notes: #{notes}" unless notes.empty?
|
|
592
599
|
llm_context = llm_context_stats_text(snapshot[:llm_context])
|
|
593
600
|
lines << "llm context: #{llm_context}" if llm_context
|
|
601
|
+
thinking = thinking_stats_text(snapshot[:thinking])
|
|
602
|
+
lines << "thinking: #{thinking}" if thinking
|
|
594
603
|
if snapshot[:served_model]
|
|
595
604
|
asked = snapshot[:served_model_for]
|
|
596
605
|
differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
|
|
@@ -600,6 +609,8 @@ module Samagotchi
|
|
|
600
609
|
lines.join("\n")
|
|
601
610
|
end
|
|
602
611
|
|
|
612
|
+
private
|
|
613
|
+
|
|
603
614
|
# " (25.0% of the 64000 budget)" when the session's llm_context budget
|
|
604
615
|
# is set and smaller than the window, else " (12.5%)": the live meter
|
|
605
616
|
# counts against the smaller of the two (ContextStatus#counted_against),
|
|
@@ -630,6 +641,17 @@ module Samagotchi
|
|
|
630
641
|
"budget #{budget} (#{get.call(:budget_where)})"
|
|
631
642
|
end
|
|
632
643
|
|
|
644
|
+
# "low (session)", "default" for /stats (Thinking::Explained#summary;
|
|
645
|
+
# symbol or string keys, the attached TUI's come as JSON); nil
|
|
646
|
+
# without one.
|
|
647
|
+
def thinking_stats_text(summary)
|
|
648
|
+
return nil unless summary.is_a?(Hash)
|
|
649
|
+
|
|
650
|
+
level = summary[:level] || summary["level"]
|
|
651
|
+
source = summary[:source] || summary["source"]
|
|
652
|
+
source ? "#{level} (#{source})" : level
|
|
653
|
+
end
|
|
654
|
+
|
|
633
655
|
# ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
|
|
634
656
|
# 212" for the tokens line; the counts a server didn't report are left
|
|
635
657
|
# out (re-prefilled: SessionMetrics' reprefill_sum).
|
|
@@ -90,9 +90,12 @@ module Samagotchi
|
|
|
90
90
|
# however the REPL ends, saves no memories and writes no recap
|
|
91
91
|
# @param llm_context [Hash{Symbol => String}] --llm-context* as typed
|
|
92
92
|
# (LLMContextOverride.update): set on the new or resumed session
|
|
93
|
+
# @param thinking [String, nil] --thinking as typed: the new or resumed
|
|
94
|
+
# session's own level (default unsets it)
|
|
93
95
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
94
|
-
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {})
|
|
96
|
+
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil)
|
|
95
97
|
@scratch = scratch
|
|
98
|
+
@thinking_word = thinking
|
|
96
99
|
@prompt = prompt
|
|
97
100
|
@llm_context_words = llm_context
|
|
98
101
|
@default_model_name = ModelProfile.required_model_name(nil)
|
|
@@ -235,6 +238,10 @@ module Samagotchi
|
|
|
235
238
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
236
239
|
# nothing and return (no transient session, no banner).
|
|
237
240
|
return if @non_interactive && @prompt.nil?
|
|
241
|
+
# A UI-only command (/stats, /exit …) isn't run without the REPL, and
|
|
242
|
+
# isn't a prompt either: refused as chi send refuses it, before any
|
|
243
|
+
# session is made (bin/chi exits 1).
|
|
244
|
+
return refuse_one_shot_ui_command if @non_interactive && SessionCommands.ui_only_entry(@prompt)
|
|
238
245
|
|
|
239
246
|
session = @resume_session || @engine.store_model!(Session.new_session(
|
|
240
247
|
mode: "assist",
|
|
@@ -246,8 +253,10 @@ module Samagotchi
|
|
|
246
253
|
))
|
|
247
254
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
248
255
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
# --thinking: the session's own level, saved at once on a resumed one.
|
|
257
|
+
unless @llm_context_words.empty? && @thinking_word.nil?
|
|
258
|
+
session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words) unless @llm_context_words.empty?
|
|
259
|
+
session.thinking = Thinking.session_level(@thinking_word) if @thinking_word
|
|
251
260
|
session.save if @resume_session
|
|
252
261
|
end
|
|
253
262
|
claim_session!(session.id) unless @owner_lock
|
|
@@ -343,7 +352,7 @@ module Samagotchi
|
|
|
343
352
|
# so the session is kept and `chi --resume` takes it up again.
|
|
344
353
|
# @return [Symbol] :turn_failed
|
|
345
354
|
def one_shot_failed(session, error)
|
|
346
|
-
warn "Error: #{
|
|
355
|
+
warn "Error: #{@engine.failure_summary(error)}"
|
|
347
356
|
if !@scratch && Session.exist?(session.id)
|
|
348
357
|
warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
|
|
349
358
|
end
|
|
@@ -366,6 +375,14 @@ module Samagotchi
|
|
|
366
375
|
:turn_failed if result.status == :error
|
|
367
376
|
end
|
|
368
377
|
|
|
378
|
+
# `chi -p /stats --non-interactive`: why it doesn't run, on stderr.
|
|
379
|
+
# @return [Symbol] :turn_failed
|
|
380
|
+
def refuse_one_shot_ui_command
|
|
381
|
+
name = SessionCommands.ui_only_entry(@prompt).name
|
|
382
|
+
warn "chi: refused: #{SessionCommands.ui_only_refusal(name, @resume_session&.id, runner: "--non-interactive")}"
|
|
383
|
+
:turn_failed
|
|
384
|
+
end
|
|
385
|
+
|
|
369
386
|
# Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
|
|
370
387
|
# a cancel note in the session (it saves only a failed turn), so save
|
|
371
388
|
# it here: `chi --resume` takes it up again. A scratch session goes in
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -10,7 +10,9 @@ module Samagotchi
|
|
|
10
10
|
# low, medium, high the effort a chat host is asked for
|
|
11
11
|
# default chi sends nothing: the provider's or template's own default
|
|
12
12
|
#
|
|
13
|
-
# Order (#resolve):
|
|
13
|
+
# Order (#resolve): the session's own level (/thinking, chi --thinking at
|
|
14
|
+
# start, chi send --new --thinking, the web), then the process default
|
|
15
|
+
# (chi web --thinking / SAMAGOTCHI_THINKING_LEVEL), then
|
|
14
16
|
# models.<key>.thinking, hosts.<name>.thinking, thinking.level in
|
|
15
17
|
# config.yml, then default.
|
|
16
18
|
module Thinking
|
|
@@ -33,8 +35,39 @@ module Samagotchi
|
|
|
33
35
|
# anything there (low/medium/high have no native knob).
|
|
34
36
|
Native = Data.define(:system_token, :prefill, :honoured)
|
|
35
37
|
|
|
38
|
+
# The source a session's own level reads as.
|
|
39
|
+
SESSION_SOURCE = "session"
|
|
40
|
+
|
|
41
|
+
# A level and where it came from (#explain): +source+ is "session",
|
|
42
|
+
# "--thinking", "SAMAGOTCHI_THINKING_LEVEL", "models: qwen",
|
|
43
|
+
# "hosts.work" or "thinking.level", nil for the default; +own+ is the
|
|
44
|
+
# session's own level, nil without one. What /thinking, /model,
|
|
45
|
+
# /stats, the snapshots and the web read.
|
|
46
|
+
Explained = Data.define(:level, :source, :own) do
|
|
47
|
+
# "low (session)", "default" with no source.
|
|
48
|
+
def label = source ? "#{level} (#{source})" : level.to_s
|
|
49
|
+
|
|
50
|
+
# The plain form the snapshots, the worker's command_ran event and
|
|
51
|
+
# the web read.
|
|
52
|
+
# @return [Hash] symbol keys
|
|
53
|
+
def summary = { level: level.to_s, source: source, own: own&.to_s }
|
|
54
|
+
end
|
|
55
|
+
|
|
36
56
|
module_function
|
|
37
57
|
|
|
58
|
+
# A session's own level from its file or a command word: :off, :low,
|
|
59
|
+
# :medium or :high; nil for anything else, default included (unset:
|
|
60
|
+
# the session follows the process default and the model). Never warns:
|
|
61
|
+
# a session file keeps a value it can't read as written.
|
|
62
|
+
# @return [Symbol, nil]
|
|
63
|
+
def session_level(value)
|
|
64
|
+
return value if LEVELS.include?(value) && value != DEFAULT
|
|
65
|
+
return nil unless value.is_a?(String)
|
|
66
|
+
|
|
67
|
+
level = value.strip.downcase.to_sym
|
|
68
|
+
LEVELS.include?(level) && level != DEFAULT ? level : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
38
71
|
# A configured value as a level; nil when unset or not a level (that
|
|
39
72
|
# warns once, naming +where+). YAML reads an unquoted `off` as false,
|
|
40
73
|
# which is :off; true (`on`) isn't a level.
|
|
@@ -51,13 +84,17 @@ module Samagotchi
|
|
|
51
84
|
nil
|
|
52
85
|
end
|
|
53
86
|
|
|
54
|
-
# The level for +target+ and where it came from ("
|
|
55
|
-
# "models: qwen", "hosts.work", "thinking.level"; nil for
|
|
87
|
+
# The level for +target+ and where it came from ("session",
|
|
88
|
+
# "--thinking", "models: qwen", "hosts.work", "thinking.level"; nil for
|
|
89
|
+
# the default).
|
|
56
90
|
# @param target [HostRegistry::ModelTarget]
|
|
57
91
|
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
58
|
-
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
92
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
93
|
+
# @param session [Symbol, nil] the session's own level (Session#thinking)
|
|
59
94
|
# @return [Array(Symbol, String|nil)]
|
|
60
|
-
def resolve(target, names:, models: nil)
|
|
95
|
+
def resolve(target, names:, models: nil, session: nil)
|
|
96
|
+
return [session, SESSION_SOURCE] if session
|
|
97
|
+
|
|
61
98
|
global, origin = global_level
|
|
62
99
|
return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
|
|
63
100
|
|
|
@@ -66,8 +103,8 @@ module Samagotchi
|
|
|
66
103
|
rescue StandardError
|
|
67
104
|
{}
|
|
68
105
|
end
|
|
69
|
-
|
|
70
|
-
return [value, "models: #{key}"] if
|
|
106
|
+
setting = ConfigFile.model_setting(names, :thinking, models: models)
|
|
107
|
+
return [setting.value, "models: #{setting.key}"] if setting
|
|
71
108
|
|
|
72
109
|
host = target.entry.thinking
|
|
73
110
|
return [host, "hosts.#{target.entry.name}"] if host
|
|
@@ -76,6 +113,13 @@ module Samagotchi
|
|
|
76
113
|
[DEFAULT, nil]
|
|
77
114
|
end
|
|
78
115
|
|
|
116
|
+
# #resolve, as an Explained with the session's own level beside it.
|
|
117
|
+
# @return [Explained]
|
|
118
|
+
def explain(target, names:, models: nil, session: nil)
|
|
119
|
+
level, source = resolve(target, names: names, models: models, session: session)
|
|
120
|
+
Explained.new(level: level, source: source, own: session)
|
|
121
|
+
end
|
|
122
|
+
|
|
79
123
|
# thinking.level as a level and its origin (:cli, :env, :file), or nil.
|
|
80
124
|
def global_level
|
|
81
125
|
raw, origin = Config.get_with_origin("thinking.level")
|
|
@@ -88,6 +132,22 @@ module Samagotchi
|
|
|
88
132
|
nil
|
|
89
133
|
end
|
|
90
134
|
|
|
135
|
+
# What a level change costs the prompt cache on +target+ (/thinking's
|
|
136
|
+
# note; a confirm step may ask before the costly ones):
|
|
137
|
+
# :full the native Gemma 4 prompt starts with <|think|>, so the
|
|
138
|
+
# whole prompt is read again;
|
|
139
|
+
# :provider a chat host gets other request fields: a local llama.cpp
|
|
140
|
+
# or Splash server keeps its cache, a hosted API may not;
|
|
141
|
+
# :tail native Qwen and the rest: only the tail changes.
|
|
142
|
+
# @param target [HostRegistry::ModelTarget]
|
|
143
|
+
# @param profile [String, nil] the native prompt profile's name
|
|
144
|
+
# @return [Symbol]
|
|
145
|
+
def cache_cost(target, profile: nil)
|
|
146
|
+
return :provider if target.entry&.chat?
|
|
147
|
+
|
|
148
|
+
profile == "gemma4" ? :full : :tail
|
|
149
|
+
end
|
|
150
|
+
|
|
91
151
|
# Request fields for an OpenAI-style chat host. off sends both switches
|
|
92
152
|
# (the template's enable_thinking and the OpenAI-style reasoning_effort
|
|
93
153
|
# "none"): llama.cpp, Splash and OpenRouter each honour one of them.
|