samagotchi 0.100.1 → 0.102.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 +50 -1
- data/bin/chi +14 -10
- data/docs/architecture.md +10 -2
- data/docs/cli.md +100 -5
- data/docs/configuration.md +26 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -0
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +205 -0
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +92 -53
- data/lib/samagotchi/engine.rb +153 -4
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/testing/fake_executable.rb +6 -16
- data/lib/samagotchi/testing/fake_executable_launcher +6 -0
- data/lib/samagotchi/tool_response.rb +10 -6
- data/lib/samagotchi/tool_runner.rb +25 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -11
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +7 -0
- data/lib/samagotchi/web/public/index.html +145 -1
- data/lib/samagotchi/web/public/info_bar.js +16 -4
- data/lib/samagotchi/web/public/info_bar_model.js +7 -3
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/stage_model.js +6 -5
- data/lib/samagotchi/web/public/stage_view.js +34 -2
- data/lib/samagotchi/web/public/stream_handlers.js +10 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/turn_view.js +16 -4
- data/lib/samagotchi/web/public/working_set.js +612 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +541 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +238 -0
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +11 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -16,6 +16,8 @@ require_relative "toolkit_command"
|
|
|
16
16
|
require_relative "parent_answers_command"
|
|
17
17
|
require_relative "title_command"
|
|
18
18
|
require_relative "reasoning_history_command"
|
|
19
|
+
require_relative "working_set_command"
|
|
20
|
+
require_relative "recap_command"
|
|
19
21
|
require_relative "guardrails/parent_policy"
|
|
20
22
|
|
|
21
23
|
module Samagotchi
|
|
@@ -40,6 +42,8 @@ module Samagotchi
|
|
|
40
42
|
PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
|
|
41
43
|
TITLE_COMMAND = TitleCommand::NAME
|
|
42
44
|
REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
|
|
45
|
+
WORKING_SET_COMMAND = WorkingSetCommand::NAME
|
|
46
|
+
RECAP_COMMAND = RecapCommand::NAME
|
|
43
47
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
44
48
|
# shows this many per host; /models <text> lists every match.
|
|
45
49
|
MODELS_PER_HOST = 20
|
|
@@ -109,6 +113,11 @@ module Samagotchi
|
|
|
109
113
|
registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
|
|
110
114
|
id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
|
|
111
115
|
match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
|
|
116
|
+
# The list reads only: mid-turn too; a drop waits for the turn's end
|
|
117
|
+
# (the next turn's first request is the first with it).
|
|
118
|
+
registry.register(WORKING_SET_COMMAND, "list the working set; drop or undrop an item (drop KEY, undrop KEY)",
|
|
119
|
+
id: :working_set, mid_turn: show_mid_turn(WORKING_SET_COMMAND),
|
|
120
|
+
match: ->(text) { text.match?(%r{\A/working-set(?:\s+.*)?\z}) }) { |text| working_set(text) }
|
|
112
121
|
# Tighten-only from anyone; loosening is a user's at a UI (#run's user_ui:).
|
|
113
122
|
registry.register(PARENT_ANSWERS_COMMAND, "show or set what a parent agent may answer here (hard|standard|soft|default; " \
|
|
114
123
|
"never looser than config.yml's)",
|
|
@@ -119,7 +128,14 @@ module Samagotchi
|
|
|
119
128
|
id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
|
|
120
129
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
121
130
|
registry.register("/stats", "show the session's stats", local: true)
|
|
122
|
-
|
|
131
|
+
# /recap with words (on|off|default, or a slip: the usage) runs here:
|
|
132
|
+
# it toggles the idle job only, so it doesn't wait for a running turn.
|
|
133
|
+
# The bare /recap shows the recap: the UIs' own, under the same name
|
|
134
|
+
# (Registry#register); /help lists the name once, with this entry's
|
|
135
|
+
# description.
|
|
136
|
+
registry.register(RECAP_COMMAND, "show the session's recap (in a terminal); on|off|default sets this session's own",
|
|
137
|
+
id: :recap_setting, anytime: true, match: ->(text) { text.match?(%r{\A/recap\s+\S}) }) { |text| recap_setting(text) }
|
|
138
|
+
registry.register(RECAP_COMMAND, "show the session's recap", local: true, match: ->(text) { text == RECAP_COMMAND })
|
|
123
139
|
# Bare `exit` too; any case; --delete after it.
|
|
124
140
|
registry.register("/exit", "leave (--delete also deletes the session)", local: true,
|
|
125
141
|
match: exit_match("/?exit"))
|
|
@@ -230,7 +246,9 @@ module Samagotchi
|
|
|
230
246
|
# own (marked by UI), each group by name.
|
|
231
247
|
# @return [String]
|
|
232
248
|
def help_listing
|
|
233
|
-
|
|
249
|
+
# A name with a UI's entry and the session's (/recap): listed once,
|
|
250
|
+
# as the session's (registered first).
|
|
251
|
+
entries = @registry.entries.uniq(&:name).sort_by { |entry| [help_group(entry), entry.name] }
|
|
234
252
|
width = entries.map { |entry| self.class.display_name(entry).length }.max
|
|
235
253
|
lines = entries.map do |entry|
|
|
236
254
|
notes = []
|
|
@@ -527,6 +545,22 @@ module Samagotchi
|
|
|
527
545
|
reply(output, status: status, changed: changed ? [:parent_answers] : [])
|
|
528
546
|
end
|
|
529
547
|
|
|
548
|
+
# /working-set (WorkingSetCommand): an op changes the conversation
|
|
549
|
+
# (changed :messages: the worker saves it, the web reads it again), so
|
|
550
|
+
# a rollback past it would drop it silently (as !cmd), and the working
|
|
551
|
+
# set goes out again (held after the command's command_ran).
|
|
552
|
+
def working_set(text)
|
|
553
|
+
output, changed, status = WorkingSetCommand.new(engine: @engine, save: ->(_session) { save_session })
|
|
554
|
+
.run(text.delete_prefix(WORKING_SET_COMMAND))
|
|
555
|
+
if changed
|
|
556
|
+
@turn_flow.note_conversation_changed
|
|
557
|
+
@engine.announce_working_set
|
|
558
|
+
end
|
|
559
|
+
reply(output, status: status, changed: changed ? [:messages] : [])
|
|
560
|
+
rescue ArgumentError => e
|
|
561
|
+
reply(e.message, status: :error)
|
|
562
|
+
end
|
|
563
|
+
|
|
530
564
|
# /title (TitleCommand); :title in changed when it named the session or
|
|
531
565
|
# cleared its name. A plain save: no setting moved, and it may run on
|
|
532
566
|
# an anytime thread beside a turn.
|
|
@@ -535,6 +569,14 @@ module Samagotchi
|
|
|
535
569
|
reply(output, status: status, changed: changed ? [:title] : [])
|
|
536
570
|
end
|
|
537
571
|
|
|
572
|
+
# /recap on|off|default (RecapCommand); :recap in changed when this
|
|
573
|
+
# session's own recap.enabled moved. It doesn't save: its host does on
|
|
574
|
+
# a changed result (Worker#run_command, the REPL).
|
|
575
|
+
def recap_setting(text)
|
|
576
|
+
output, changed, status = RecapCommand.new(engine: @engine).run(text.delete_prefix(RECAP_COMMAND))
|
|
577
|
+
reply(output, status: status, changed: changed ? [:recap] : [])
|
|
578
|
+
end
|
|
579
|
+
|
|
538
580
|
# A session setting changed (/llm-context, /thinking, /reasoning-history):
|
|
539
581
|
# saved with its model.
|
|
540
582
|
def save_setup(session)
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# A session's own settings (chi --set, chi send --new --set, POST
|
|
9
|
+
# /api/sessions settings), kept as one value (Settings). Its core part:
|
|
10
|
+
# {key => value} for the core config keys a session may set
|
|
11
|
+
# (Config::Entry#session?), typed by their entry (Config.coerce!) and
|
|
12
|
+
# read through Config.get(key, session:) by the readers wired to it
|
|
13
|
+
# (RecapSetup). Its
|
|
14
|
+
# bundles part: {bundle => {key => value}}, string keys, scalar values
|
|
15
|
+
# (.typed). A bundle offers the keys a session may set in its manifest's
|
|
16
|
+
# session_settings: (recorded at install, InstalledBundle#session_settings);
|
|
17
|
+
# any other bundle or key is refused, so a typo isn't saved, and so is a
|
|
18
|
+
# value of another type than the manifest gives the key (session_settings:
|
|
19
|
+
# {cap: number}). The session keeps them (Session#settings, SessionSetup)
|
|
20
|
+
# and Plugin::Context#settings lays them over config.yml's bundles: <name>:.
|
|
21
|
+
module SessionConfig
|
|
22
|
+
# A refused --set (or request field): the message says why.
|
|
23
|
+
class Error < ArgumentError; end
|
|
24
|
+
|
|
25
|
+
# A bundle name (MemoryBundle::Manifest::BUNDLE_NAME) and a setting key.
|
|
26
|
+
BUNDLE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
27
|
+
KEY = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
28
|
+
# The values a setting may take.
|
|
29
|
+
SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
|
|
30
|
+
REQUEST_SHAPE = "settings is an object of KEY or BUNDLE.KEY => scalar"
|
|
31
|
+
# What --set takes, as a malformed one is told.
|
|
32
|
+
FORMS = "KEY=VALUE or BUNDLE.KEY=VALUE"
|
|
33
|
+
# What .typed reads as a number: no leading zero, no _; a decimal may
|
|
34
|
+
# leave out either side of its point (.5, 5.) and take an exponent
|
|
35
|
+
# (1e1, 2.5e-3), which YAML 1.1 leaves as text.
|
|
36
|
+
INTEGER = /\A-?(?:0|[1-9]\d*)\z/
|
|
37
|
+
DECIMAL = /\A-?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
|
|
38
|
+
# YAML 1.1's booleans (config.yml's), any case.
|
|
39
|
+
TRUE_WORDS = %w[true yes on].freeze
|
|
40
|
+
FALSE_WORDS = %w[false no off].freeze
|
|
41
|
+
# The shipped bundles' sources (a stale record's hint reads them).
|
|
42
|
+
SHIPPED_DIR = File.expand_path("bundles", __dir__)
|
|
43
|
+
|
|
44
|
+
# A session's own settings, as one value: +core+ its core config keys
|
|
45
|
+
# ({key => value}, .normalize_core's shape), +bundles+ its bundle
|
|
46
|
+
# settings ({bundle => {key => value}}, .normalize's shape). Both
|
|
47
|
+
# frozen, string keys; the session file keeps +core+ under
|
|
48
|
+
# config_settings and +bundles+ under bundle_settings.
|
|
49
|
+
Settings = Data.define(:core, :bundles) do
|
|
50
|
+
def initialize(core: {}, bundles: {})
|
|
51
|
+
super(core: SessionConfig.normalize_core(core), bundles: SessionConfig.normalize(bundles))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A Settings as given, nil as none (EMPTY).
|
|
55
|
+
# @param value [Settings, nil]
|
|
56
|
+
# @return [Settings]
|
|
57
|
+
# @raise [ArgumentError] anything else
|
|
58
|
+
def self.of(value)
|
|
59
|
+
return value if value.is_a?(Settings)
|
|
60
|
+
return Settings::EMPTY if value.nil?
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, "session settings are a SessionConfig::Settings, not #{value.class}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def empty? = core.empty? && bundles.empty?
|
|
66
|
+
|
|
67
|
+
# Both parts as --set names them: {"recap.enabled" => false,
|
|
68
|
+
# "cost-cap.cap" => 3} (/stats, chi sessions stats --format json).
|
|
69
|
+
# @return [Hash{String => Object}]
|
|
70
|
+
def flat
|
|
71
|
+
core.merge(bundles.flat_map { |bundle, values| values.map { |key, value| ["#{bundle}.#{key}", value] } }.to_h)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# "recap.enabled=false · cost-cap.cap=3", "" for none.
|
|
75
|
+
def text = SessionConfig.text(flat)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The installed bundles a session may set settings of: +offered+ the
|
|
79
|
+
# keys of each bundle whose plugin loads ({name => keys}), +notes+ what
|
|
80
|
+
# a refusal adds for a bundle ({name => text}): why its plugin doesn't
|
|
81
|
+
# load, or how to refresh a record from before session_settings;
|
|
82
|
+
# +types+ the types its manifest gives keys ({name => {key => type}}).
|
|
83
|
+
Availability = Data.define(:offered, :notes, :types) do
|
|
84
|
+
def initialize(offered:, notes:, types: {}) = super
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# What each type (MemoryBundle::Manifest::SESSION_SETTING_TYPES) takes,
|
|
88
|
+
# as a value .typed gives it, and how a refusal names it. A number may
|
|
89
|
+
# be off (false), as a bundle's own settings are turned off.
|
|
90
|
+
TYPES = {
|
|
91
|
+
"number" => [->(value) { (value.is_a?(Numeric) && value.finite?) || value == false }, "a number (or off)"],
|
|
92
|
+
"boolean" => [->(value) { [true, false].include?(value) }, "true or false"],
|
|
93
|
+
"string" => [->(value) { value.is_a?(String) }, "text"]
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
module_function
|
|
97
|
+
|
|
98
|
+
# --set's values as typed, in order: a later one of the same key wins.
|
|
99
|
+
# A name a core config entry has (recap.enabled), or one with no dot,
|
|
100
|
+
# is a core key (.core_value: a session-settable one, typed by its
|
|
101
|
+
# entry); any other is BUNDLE.KEY, its value .typed (a key +types+
|
|
102
|
+
# gives as string keeps its text as given, .value_for).
|
|
103
|
+
# @param args [Array<String>] "KEY=VALUE" or "BUNDLE.KEY=VALUE" each
|
|
104
|
+
# @param types [Hash{String => Hash{String => String}}] Availability#types
|
|
105
|
+
# @param bundles [Hash, nil] the offered keys an unknown dotless key's
|
|
106
|
+
# refusal names (.offered by default)
|
|
107
|
+
# @return [Settings]
|
|
108
|
+
# @raise [Error] a value of another shape, a core key no session sets
|
|
109
|
+
def parse(args, types: {}, bundles: nil)
|
|
110
|
+
core = {}
|
|
111
|
+
given = {}
|
|
112
|
+
Array(args).each do |arg|
|
|
113
|
+
target, sep, raw = arg.to_s.partition("=")
|
|
114
|
+
raise Error, "--set takes #{FORMS}, not #{arg}" if sep.empty?
|
|
115
|
+
|
|
116
|
+
name = split(target, "--set #{arg}") { raise Error, "--set takes #{FORMS}, not #{arg}" }
|
|
117
|
+
raise Error, "--set #{arg}: give a value" if raw.strip.empty?
|
|
118
|
+
|
|
119
|
+
if name.is_a?(String)
|
|
120
|
+
core[name] = core_value(name, raw, flag: "--set", bundles: bundles)
|
|
121
|
+
else
|
|
122
|
+
bundle, key = name
|
|
123
|
+
(given[bundle] ||= {})[key] = value_for(raw, types.dig(bundle, key))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
Settings.new(core: core, bundles: given)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# +target+ (the part before =) as a core key (a String) or [bundle,
|
|
130
|
+
# key]; yields when it is no name at all.
|
|
131
|
+
def split(target, where)
|
|
132
|
+
return target if Config.find_by_key(target)
|
|
133
|
+
|
|
134
|
+
bundle, *keys = target.split(".", -1)
|
|
135
|
+
yield if keys.empty? ? !KEY.match?(bundle.to_s) : !BUNDLE.match?(bundle.to_s) || keys.any? { |key| !KEY.match?(key) }
|
|
136
|
+
return target if keys.empty?
|
|
137
|
+
raise Error, "#{where}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
|
|
138
|
+
|
|
139
|
+
[bundle, keys.first]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A core key's value: +key+ names a session-settable entry, and +raw+
|
|
143
|
+
# types as it (Config.coerce!) and passes its check:.
|
|
144
|
+
# @raise [Error] "FLAG KEY: why"
|
|
145
|
+
def core_value(key, raw, flag:, bundles: nil)
|
|
146
|
+
entry = Config.find_by_key(key)
|
|
147
|
+
raise Error, "#{flag} #{key}: #{unknown_core(key, bundles: bundles)}" unless entry
|
|
148
|
+
raise Error, "#{flag} #{key}: #{key} is not a session setting (#{settable_text})" unless entry.session?
|
|
149
|
+
|
|
150
|
+
value = begin
|
|
151
|
+
Config.coerce!(entry, raw)
|
|
152
|
+
rescue Error => e
|
|
153
|
+
raise Error, "#{flag} #{key}: #{e.message}"
|
|
154
|
+
end
|
|
155
|
+
if (takes = entry.check&.call(value))
|
|
156
|
+
raise Error, "#{flag} #{key}: #{key} takes #{takes}, not #{raw.to_s.inspect}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Why +key+ (no config entry) is no session setting, with the settable
|
|
163
|
+
# keys it is near; a dotless or non-core-section key also names the
|
|
164
|
+
# bundles that take --set (it may have meant one).
|
|
165
|
+
def unknown_core(key, bundles: nil)
|
|
166
|
+
near = Config.near_names(key, Config.session_entries.map(&:key))
|
|
167
|
+
hint = near.empty? ? settable_text : "did you mean #{near.join(" or ")}?"
|
|
168
|
+
hint = "#{hint}; #{takers(bundles || offered)}" unless core_section?(key.split(".").first)
|
|
169
|
+
"no session setting #{key} (#{hint})"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# "session settings: recap.enabled, recap.sentences"
|
|
173
|
+
def settable_text = "session settings: #{Config.session_entries.map(&:key).join(", ")}"
|
|
174
|
+
|
|
175
|
+
# Whether +name+ is a core config section (recap, guardrails).
|
|
176
|
+
def core_section?(name) = Config::ENTRIES.any? { |entry| entry.yaml_path.size > 1 && entry.section == name }
|
|
177
|
+
|
|
178
|
+
# +settings+ (a Settings, or its bundles part) when every bundle is
|
|
179
|
+
# installed and offers each key; a bundle that isn't installed but
|
|
180
|
+
# names a core section (recap.enable) is a core key no session sets.
|
|
181
|
+
# Its core part was checked as it was typed (.parse, .from_request).
|
|
182
|
+
# @param bundles [Hash{String => Array<String>}] the installed bundles'
|
|
183
|
+
# offered keys (.offered)
|
|
184
|
+
# @param notes [Hash{String => String}] Availability#notes
|
|
185
|
+
# @param types [Hash{String => Hash{String => String}}] Availability#types:
|
|
186
|
+
# a typed key's value must be of its type (TYPES)
|
|
187
|
+
# @param flag [String] what a refusal names ("--set", a request field)
|
|
188
|
+
# @return [Settings, Hash] +settings+
|
|
189
|
+
# @raise [Error]
|
|
190
|
+
def check!(settings, bundles: offered, notes: {}, types: {}, flag: "--set")
|
|
191
|
+
(settings.is_a?(Settings) ? settings.bundles : settings).each do |bundle, values|
|
|
192
|
+
keys = bundles[bundle]
|
|
193
|
+
note = notes[bundle]
|
|
194
|
+
values.each do |key, value|
|
|
195
|
+
where = "#{flag} #{bundle}.#{key}"
|
|
196
|
+
if keys.nil?
|
|
197
|
+
raise Error, "#{where}: #{unknown_core("#{bundle}.#{key}", bundles: bundles)}" if note.nil? && core_section?(bundle)
|
|
198
|
+
|
|
199
|
+
raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}"
|
|
200
|
+
end
|
|
201
|
+
raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
|
|
202
|
+
raise Error, "#{where}: #{bundle} takes #{keys.join(", ")}" unless keys.include?(key)
|
|
203
|
+
|
|
204
|
+
takes, what = TYPES[types.dig(bundle, key)]
|
|
205
|
+
raise Error, "#{where}: #{bundle} takes #{what} for #{key}, not #{value.inspect}" if takes && !takes.call(value)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
settings
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# --set's values parsed and checked (.parse, .check!) against the
|
|
212
|
+
# installed bundles (.availability).
|
|
213
|
+
# @return [Settings]
|
|
214
|
+
def from_flags(args, available: availability)
|
|
215
|
+
check!(parse(args, types: available.types, bundles: available.offered), bundles: available.offered, notes: available.notes, types: available.types)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A value as --set (and a request's string) gives it: an integer
|
|
219
|
+
# ("3", "-1"), a decimal ("2.5", ".5", "1e1"), a boolean as config.yml's
|
|
220
|
+
# YAML reads one (true/yes/on, false/no/off, any case); any other string
|
|
221
|
+
# stays as given ("010", "0x10", "3:30", "y", "null"). A value that
|
|
222
|
+
# isn't a String (a JSON number or boolean) is kept.
|
|
223
|
+
def typed(value)
|
|
224
|
+
return value unless value.is_a?(String)
|
|
225
|
+
return Integer(value, 10) if INTEGER.match?(value)
|
|
226
|
+
|
|
227
|
+
if DECIMAL.match?(value)
|
|
228
|
+
number = Float(float_text(value))
|
|
229
|
+
# 1e400 is Infinity: no cap reaches it and JSON can't save it.
|
|
230
|
+
return number.finite? ? number : value
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
word = value.downcase
|
|
234
|
+
return true if TRUE_WORDS.include?(word)
|
|
235
|
+
return false if FALSE_WORDS.include?(word)
|
|
236
|
+
|
|
237
|
+
value
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# A value for a key of +type+: a string key's text as given (--set
|
|
241
|
+
# b.name=yes stays "yes"), any other's .typed.
|
|
242
|
+
def value_for(value, type) = type == "string" ? value : typed(value)
|
|
243
|
+
|
|
244
|
+
# A decimal DECIMAL matches as Float reads it on every Ruby: a point
|
|
245
|
+
# with no digit after it ("5.", "5.e1") gets a 0, which Ruby 3.3's
|
|
246
|
+
# Float wants.
|
|
247
|
+
def float_text(value) = value.sub(/\.(?=e|\z)/i, ".0")
|
|
248
|
+
|
|
249
|
+
# POST /api/sessions settings: nil for none, else an object of names
|
|
250
|
+
# as --set gives them (KEY or BUNDLE.KEY) to scalars (a string typed as
|
|
251
|
+
# --set types it), checked like --set.
|
|
252
|
+
# @param bundles [Hash, nil] the offered keys (.availability's by default)
|
|
253
|
+
# @param types [Hash] their types, with +bundles+ (Availability#types)
|
|
254
|
+
# @return [Settings]
|
|
255
|
+
# @raise [Error]
|
|
256
|
+
def from_request(raw, bundles: nil, types: {})
|
|
257
|
+
return Settings::EMPTY if raw.nil?
|
|
258
|
+
raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && scalar_map?(raw)
|
|
259
|
+
return Settings::EMPTY if raw.empty?
|
|
260
|
+
|
|
261
|
+
available = bundles ? Availability.new(offered: bundles, notes: {}, types: types) : availability
|
|
262
|
+
core = {}
|
|
263
|
+
given = {}
|
|
264
|
+
raw.each do |target, value|
|
|
265
|
+
target = target.to_s
|
|
266
|
+
name = split(target, "settings #{target}") { raise Error, "settings takes KEY or BUNDLE.KEY names, not #{target.inspect}" }
|
|
267
|
+
raise Error, "settings #{target}: give a value" if value.is_a?(String) && value.strip.empty?
|
|
268
|
+
|
|
269
|
+
if name.is_a?(String)
|
|
270
|
+
core[name] = core_value(name, value, flag: "settings", bundles: available.offered)
|
|
271
|
+
else
|
|
272
|
+
bundle, key = name
|
|
273
|
+
(given[bundle] ||= {})[key] = value_for(value, available.types.dig(bundle, key))
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
check!(Settings.new(core: core, bundles: given), bundles: available.offered, notes: available.notes,
|
|
277
|
+
types: available.types, flag: "settings")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Each installed bundle's offered keys, by name (Availability#offered).
|
|
281
|
+
# @return [Hash{String => Array<String>}]
|
|
282
|
+
def offered(dir: nil) = availability(dir: dir).offered
|
|
283
|
+
|
|
284
|
+
# The installed bundles a session may set settings of: a record that
|
|
285
|
+
# doesn't read is left out, and so is one whose plugin doesn't load as
|
|
286
|
+
# chi's loader decides (Plugin::Loader#unloadable_reason: requires_chi,
|
|
287
|
+
# the file, its sha256), with a note saying why. A record with no
|
|
288
|
+
# session_settings whose shipped bundle lists some (installed before
|
|
289
|
+
# them) gets a note on refreshing it.
|
|
290
|
+
# @return [Availability]
|
|
291
|
+
def availability(dir: nil, shipped_dir: SHIPPED_DIR)
|
|
292
|
+
require_relative "memory_bundle/provenance"
|
|
293
|
+
require_relative "memory_bundle/manifest"
|
|
294
|
+
dir ||= MemoryBundle::Provenance.bundles_dir
|
|
295
|
+
offered = {}
|
|
296
|
+
notes = {}
|
|
297
|
+
types = {}
|
|
298
|
+
MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
|
|
299
|
+
next if bundle.error?
|
|
300
|
+
|
|
301
|
+
if (reason = unloadable(dir, name, bundle))
|
|
302
|
+
notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
|
|
303
|
+
next
|
|
304
|
+
end
|
|
305
|
+
offered[name] = bundle.session_settings
|
|
306
|
+
types[name] = bundle.session_setting_types unless bundle.session_setting_types.empty?
|
|
307
|
+
if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
|
|
308
|
+
notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
Availability.new(offered: offered, notes: notes, types: types)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# A session file's value, read tolerantly: the bundles that map to
|
|
315
|
+
# settings, the scalar ones of those, string keys, frozen; {} for
|
|
316
|
+
# anything else.
|
|
317
|
+
# @return [Hash{String => Hash{String => Object}}]
|
|
318
|
+
def normalize(raw)
|
|
319
|
+
return {}.freeze unless raw.is_a?(Hash)
|
|
320
|
+
|
|
321
|
+
deep_freeze(raw.each_with_object({}) do |(bundle, values), acc|
|
|
322
|
+
next unless values.is_a?(Hash)
|
|
323
|
+
|
|
324
|
+
kept = values.each_with_object({}) do |(key, value), scalars|
|
|
325
|
+
scalars[key.to_s] = value if SCALARS.any? { |kind| value.is_a?(kind) }
|
|
326
|
+
end
|
|
327
|
+
acc[bundle.to_s] = kept unless kept.empty?
|
|
328
|
+
end)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Settings#core read tolerantly: the session-settable keys
|
|
332
|
+
# (Config::Entry#session?) whose value types as their entry and passes
|
|
333
|
+
# its check:, typed, string keys, frozen; {} for anything else (a key
|
|
334
|
+
# that is no longer settable, a hand-edited value).
|
|
335
|
+
# @return [Hash{String => Object}]
|
|
336
|
+
def normalize_core(raw)
|
|
337
|
+
return {}.freeze unless raw.is_a?(Hash)
|
|
338
|
+
|
|
339
|
+
raw.each_with_object({}) do |(key, value), kept|
|
|
340
|
+
entry = Config.find_by_key(key.to_s)
|
|
341
|
+
next unless entry&.session? && SCALARS.any? { |kind| value.is_a?(kind) }
|
|
342
|
+
|
|
343
|
+
typed = begin
|
|
344
|
+
Config.coerce!(entry, value)
|
|
345
|
+
rescue Error
|
|
346
|
+
next
|
|
347
|
+
end
|
|
348
|
+
next if typed.nil? || entry.check&.call(typed)
|
|
349
|
+
|
|
350
|
+
kept[entry.key] = typed.frozen? ? typed : typed.dup.freeze
|
|
351
|
+
end.freeze
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# "KEY=VALUE · KEY=VALUE" for {key => value} (Settings#flat), "" for none.
|
|
355
|
+
def text(flat) = flat.map { |key, value| "#{key}=#{value}" }.join(" · ")
|
|
356
|
+
|
|
357
|
+
# Why a bundle +name+ can't offer the session settings +keys+: one
|
|
358
|
+
# whose NAME.KEY is a session-settable core key (--set NAME.KEY sets
|
|
359
|
+
# chi's), nil when none clashes. The install refuses it and chi bundle
|
|
360
|
+
# check reports it (clash), in these words.
|
|
361
|
+
# @return [String, nil]
|
|
362
|
+
def clash(name, keys)
|
|
363
|
+
clashing = Array(keys).map { |key| "#{name}.#{key}" } & Config.session_entries.map(&:key)
|
|
364
|
+
return nil if clashing.empty?
|
|
365
|
+
|
|
366
|
+
"session_settings: #{clashing.join(", ")} #{clashing.size == 1 ? "is" : "are"} chi's own session " \
|
|
367
|
+
"setting#{"s" unless clashing.size == 1} (--set #{clashing.first}=… sets chi's); rename the setting or the bundle"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Why +bundle+'s plugin doesn't load, as chi's loader decides it
|
|
371
|
+
# (Plugin::Loader.unloadable_reason), nil when it does or has none.
|
|
372
|
+
def unloadable(dir, name, bundle)
|
|
373
|
+
return nil unless bundle.plugin
|
|
374
|
+
|
|
375
|
+
require_relative "plugin/loader"
|
|
376
|
+
# +dir+, not Provenance#plugin_path's bundles_dir: a spec's records live elsewhere.
|
|
377
|
+
Plugin::Loader.unloadable_reason(File.join(dir, name, "plugin", bundle.plugin.file.to_s), bundle)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Whether chi ships a bundle +name+ whose manifest lists session_settings.
|
|
381
|
+
def shipped_settings?(shipped_dir, name)
|
|
382
|
+
path = File.join(shipped_dir, name, "manifest.yml")
|
|
383
|
+
return false unless File.file?(path)
|
|
384
|
+
|
|
385
|
+
raw = YAML.safe_load_file(path)
|
|
386
|
+
listed = raw.is_a?(Hash) ? raw["session_settings"] : nil
|
|
387
|
+
(listed.is_a?(Array) || listed.is_a?(Hash)) && !listed.empty?
|
|
388
|
+
rescue Psych::Exception, SystemCallError
|
|
389
|
+
false
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def scalar_map?(values)
|
|
393
|
+
values.is_a?(Hash) && values.values.all? { |value| SCALARS.any? { |kind| value.is_a?(kind) } }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def takers(bundles)
|
|
397
|
+
names = bundles.reject { |_, keys| keys.empty? }.keys.sort
|
|
398
|
+
names.empty? ? "no installed bundle takes --set" : "bundles that take --set: #{names.join(", ")}"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def deep_freeze(settings)
|
|
402
|
+
settings.to_h { |bundle, values| [bundle.dup.freeze, values.to_h { |k, v| [k.dup.freeze, v.frozen? ? v : v.dup.freeze] }.freeze] }.freeze
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
private_class_method :unloadable, :shipped_settings?, :scalar_map?, :takers, :deep_freeze, :split, :unknown_core,
|
|
406
|
+
:settable_text, :core_section?
|
|
407
|
+
|
|
408
|
+
# No settings (built once the module's functions are defined).
|
|
409
|
+
Settings::EMPTY = Settings.new
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -4,7 +4,7 @@ require_relative "llm_context_override"
|
|
|
4
4
|
require_relative "reasoning_history"
|
|
5
5
|
require_relative "thinking"
|
|
6
6
|
require_relative "toolkit"
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "session_config"
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
# A session's own settings that come before its model's, carried as one
|
|
@@ -16,9 +16,9 @@ module Samagotchi
|
|
|
16
16
|
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
17
17
|
# named (their text goes into the system prompt; [] with none),
|
|
18
18
|
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
19
|
-
# session config's toolkit.default then), +
|
|
20
|
-
# settings chi --set gave (
|
|
21
|
-
#
|
|
19
|
+
# session config's toolkit.default then), +settings+ the session's own
|
|
20
|
+
# settings chi --set gave (SessionConfig::Settings; its bundles part is
|
|
21
|
+
# what Plugin::Context#settings lays over config.yml's),
|
|
22
22
|
# +parent_answers+ the session's own guardrails.parent_answers (a level
|
|
23
23
|
# String, tighten-only: Guardrails::ParentPolicy.effective) or nil.
|
|
24
24
|
#
|
|
@@ -27,17 +27,17 @@ module Samagotchi
|
|
|
27
27
|
# without one (but the toolkit its delegate call named, and its parent's
|
|
28
28
|
# parent_answers); a continue and a fork copy their session's (.of).
|
|
29
29
|
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
|
|
30
|
-
:
|
|
30
|
+
:settings, :parent_answers) do
|
|
31
31
|
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
32
|
-
|
|
32
|
+
settings: nil, parent_answers: nil)
|
|
33
33
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
34
34
|
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
35
|
-
|
|
35
|
+
settings: SessionConfig::Settings.of(settings), parent_answers: parent_answers&.to_s)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def empty?
|
|
39
39
|
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
|
|
40
|
-
|
|
40
|
+
settings.empty? && parent_answers.nil?
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# A session's own setup, for a session that starts from it. Its
|
|
@@ -50,7 +50,7 @@ module Samagotchi
|
|
|
50
50
|
|
|
51
51
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
52
52
|
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
|
|
53
|
-
|
|
53
|
+
settings: session.settings, parent_answers: session.parent_answers)
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -39,7 +39,7 @@ module Samagotchi
|
|
|
39
39
|
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
40
40
|
clean [--dry-run] [--days N] [--all] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
41
41
|
--all: every session, not only test and scratch ones (by the age and count limits, as prune)
|
|
42
|
-
stats ID [--format text|json] # a session's cost, tokens and
|
|
42
|
+
stats ID [--format text|json] # a session's cost, tokens, progress and own settings, without running a model turn:
|
|
43
43
|
a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
|
|
44
44
|
Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
|
|
45
45
|
TEXT
|
|
@@ -323,10 +323,14 @@ module Samagotchi
|
|
|
323
323
|
end
|
|
324
324
|
|
|
325
325
|
metrics, live, note = stats_snapshot_for(session)
|
|
326
|
+
# The session's own settings (chi --set), as --set names them.
|
|
327
|
+
settings = session.settings.flat
|
|
326
328
|
if format == "json"
|
|
327
|
-
@stdout.puts JSON.generate({ session_id: session.id, status: session.status, live: live, metrics: metrics
|
|
329
|
+
@stdout.puts JSON.generate({ session_id: session.id, status: session.status, live: live, metrics: metrics,
|
|
330
|
+
settings: settings })
|
|
328
331
|
else
|
|
329
332
|
@stdout.puts "#{session.id[0, 8]} #{session.status.ljust(7)} #{session.model_name}"
|
|
333
|
+
metrics = metrics.merge(settings: settings) if metrics && !settings.empty?
|
|
330
334
|
@stdout.puts TerminalUI::Formatting.format_session_metrics(metrics)
|
|
331
335
|
@stdout.puts note if note
|
|
332
336
|
end
|
|
@@ -51,7 +51,7 @@ module Samagotchi
|
|
|
51
51
|
# session keeps its own
|
|
52
52
|
# @param toolkit_flags [Array<String>] which of those were given, for
|
|
53
53
|
# the line an existing session's run prints (--set too)
|
|
54
|
-
# @param
|
|
54
|
+
# @param settings [SessionConfig::Settings, nil] --set's values:
|
|
55
55
|
# a new session's setup carries them; an existing session keeps its own
|
|
56
56
|
# @param parent_answers [String, nil] --parent-answers: a new session's
|
|
57
57
|
# own guardrails.parent_answers; an existing session keeps its own
|
|
@@ -64,12 +64,12 @@ module Samagotchi
|
|
|
64
64
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
65
65
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
66
66
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
67
|
-
toolkit_flags: [],
|
|
67
|
+
toolkit_flags: [], settings: nil, parent_answers: nil, state_dir: nil)
|
|
68
68
|
state_dir ||= Session.default_state_dir
|
|
69
69
|
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
70
70
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
71
71
|
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
|
|
72
|
-
|
|
72
|
+
settings: settings, parent_answers: parent_answers)
|
|
73
73
|
commands = []
|
|
74
74
|
commands << "/model #{model}" if model && (attach || resume)
|
|
75
75
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -143,7 +143,7 @@ module Samagotchi
|
|
|
143
143
|
# @raise [Error]
|
|
144
144
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
145
145
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
146
|
-
toolkit_flags: [],
|
|
146
|
+
toolkit_flags: [], settings: nil, parent_answers: nil, err: $stderr)
|
|
147
147
|
sd = state_dir || Session.default_state_dir
|
|
148
148
|
if attach || resume
|
|
149
149
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
|
|
@@ -165,7 +165,7 @@ module Samagotchi
|
|
|
165
165
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
166
166
|
thinking: Thinking.session_level(thinking),
|
|
167
167
|
appended_prompt_files: appended_prompt_files,
|
|
168
|
-
toolkit: toolkit,
|
|
168
|
+
toolkit: toolkit, settings: settings,
|
|
169
169
|
parent_answers: parent_answers))
|
|
170
170
|
end
|
|
171
171
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
@@ -968,7 +968,8 @@ module Samagotchi
|
|
|
968
968
|
return @screen.commit("(no recap: the worker did not answer)") unless answer
|
|
969
969
|
|
|
970
970
|
@screen.commit(recap_command_text(enabled: answer["enabled"], saved: answer["saved"],
|
|
971
|
-
request: answer["request"], min_user_turns: answer["min_user_turns"]
|
|
971
|
+
request: answer["request"], min_user_turns: answer["min_user_turns"],
|
|
972
|
+
session_off: answer["session_off"] == true))
|
|
972
973
|
rescue SystemCallError, IOError
|
|
973
974
|
@screen.commit("(no recap: the worker did not answer)")
|
|
974
975
|
end
|